From 834c31827195ea61da8bdd67f44a636705ace8ee Mon Sep 17 00:00:00 2001 From: db123 Date: Wed, 24 Jun 2026 20:39:51 +0330 Subject: [PATCH] feat: add /summary, /setbreak, weekly totals, and fix command dispatch for arg-bearing commands - Add /summary [YYYY-MM] command for monthly work/break/day-off totals - Add weekly totals row to main menu status - Add /setbreak for configurable daily break threshold - Fix HandleMessage routing: extract command name instead of exact match (fixes /export YYYY-MM, /edit, /note, /summary, /setbreak) - Update README with full command table and configurable break docs --- README.md | 32 ++++---- internal/bot/handlers.go | 23 +++++- internal/bot/report.go | 10 +++ internal/bot/settings.go | 37 ++++++++++ internal/bot/summary.go | 139 +++++++++++++++++++++++++++++++++++ internal/bot/summary_test.go | 29 ++++++++ 6 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 internal/bot/summary.go create mode 100644 internal/bot/summary_test.go diff --git a/README.md b/README.md index ef4182d..e2e994c 100644 --- a/README.md +++ b/README.md @@ -52,18 +52,24 @@ The history view, export month picker, and date displays all adapt to the user's ### Commands -| Command | Action | -| --------------------- | ------------------------------------------------------- | -| `/start` | Show inline menu | -| `/clockin` | Record clock-in | -| `/clockout` | Record clock-out | -| `/remote` | Toggle remote/onsite | -| `/report` | Today's work & break summary | -| `/export` | Export this month (optional `YYYY-MM` in your calendar) | -| `/dayoff` | Toggle today as day off | -| `/edit YYYY-MM-DD` | View/edit events for a specific date | -| `/settimezone ` | Set timezone (e.g. `Asia/Tehran`) | -| `/settings` | Open settings menu | +| Command | Action | +| --------------------------- | ----------------------------------------------------------- | +| `/start` | Show inline menu | +| `/clockin` | Record clock-in | +| `/clockout` | Record clock-out | +| `/remote` | Toggle remote/onsite | +| `/report` | Today's work & break summary | +| `/export [YYYY-MM]` | Export monthly report (optional `YYYY-MM` in your calendar) | +| `/summary [YYYY-MM]` | Show monthly summary (optional `YYYY-MM`) | +| `/dayoff` | Toggle today as day off | +| `/edit YYYY-MM-DD` | View/edit events for a specific date | +| `/note [DATE] HH:MM ` | Set note for an event at a specific time | +| `/settimezone ` | Set timezone (e.g. `Asia/Tehran`) | +| `/setbreak ` | Set minimum break threshold (0-480, default 5) | +| `/setaccent` | Select export accent color | +| `/setreporttime HH:MM` | Set daily auto-report time | +| `/reporttoggle` | Enable/disable daily auto-report | +| `/settings` | Open settings menu | ## Daily Report @@ -71,7 +77,7 @@ A daily summary is sent automatically at the user's configured report time (defa ## How Break Time Works -The gap between a clock-out and the next clock-in is counted as break. Gaps shorter than the **min break threshold** (default 5 minutes) are absorbed into work time. +The gap between a clock-out and the next clock-in is counted as break. Gaps shorter than the **min break threshold** (default 5 minutes) are absorbed into work time. Use `/setbreak ` to adjust per day (0-480). ``` 09:00 Clock In → work starts diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index de87a49..2fd30fc 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -106,6 +106,9 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { return } + if msg.Text == "" { + return + } user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { return @@ -122,7 +125,7 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { delete(h.pendingNotes, msg.Chat.ID) } h.mu.Unlock() - if hasPN && msg.Text != "" && msg.Text[0] != '/' { + if hasPN && msg.Text[0] != '/' { loc := loadLocation(user.Timezone) event, err := h.getEventByID(user.ID, pn.eventID) if err == nil { @@ -135,7 +138,17 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { return } - switch msg.Text { + if msg.Text[0] != '/' { + h.sendText(ctx, msg.Chat.ID, "Unknown command") + return + } + + cmd := msg.Text + if i := strings.IndexByte(msg.Text, ' '); i >= 0 { + cmd = msg.Text[:i] + } + + switch cmd { case "/start": h.handleStart(ctx, msg) case "/help": @@ -166,6 +179,10 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { h.handleSetTimezone(ctx, msg) case "/note": h.handleNoteMsg(ctx, msg) + case "/summary": + h.handleSummaryMsg(ctx, msg) + case "/setbreak": + h.handleSetBreakMsg(ctx, msg) default: h.sendText(ctx, msg.Chat.ID, "Unknown command") } @@ -294,10 +311,12 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) { /history - View calendar history /edit YYYY-MM-DD - Edit a specific day /note [YYYY-MM-DD] HH:MM - Set note for an event +/summary [YYYY-MM] - Show monthly summary (current month if omitted) /reporttoggle - Enable/disable auto report /setreporttime HH:MM - Set daily report time /setaccent - Select accent color /settimezone - Set your timezone (e.g. Asia/Tehran) +/setbreak - Set minimum break threshold in minutes Dates use your calendar type (Gregorian/Jalali/Hijri). Buttons on the main menu also work.` diff --git a/internal/bot/report.go b/internal/bot/report.go index 09ffcf5..52dac47 100644 --- a/internal/bot/report.go +++ b/internal/bot/report.go @@ -49,6 +49,16 @@ func (h *Handler) buildStatusText(chatID int64) string { text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds)) } + loc := loadLocation(user.Timezone) + weekTotal := h.computeWeekTotals(user, loc) + if weekTotal > 0 { + text += fmt.Sprintf("\nWeek: %s", formatDuration(weekTotal)) + } + + if day.MinBreakThreshold > 0 { + text += fmt.Sprintf("\nBreak threshold: %dm", day.MinBreakThreshold/60) + } + text += "\n\nChoose an action:" return text } diff --git a/internal/bot/settings.go b/internal/bot/settings.go index 36611c5..6cca0b8 100644 --- a/internal/bot/settings.go +++ b/internal/bot/settings.go @@ -95,6 +95,43 @@ func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message) { h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now.Format("15:04"))) } +// handleSetBreakMsg sets the minimum break threshold for today. +func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) { + args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setbreak")) + if args == "" { + _, day, err := h.getUserToday(msg.Chat.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading profile") + return + } + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current break threshold: %dm\nUsage: /setbreak (e.g. /setbreak 15)", day.MinBreakThreshold/60)) + return + } + mins, err := strconv.Atoi(args) + if err != nil || mins < 0 || mins > 480 { + h.sendText(ctx, msg.Chat.ID, "Invalid value. Must be between 0 and 480 minutes (8 hours).") + return + } + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading profile") + return + } + now := time.Now() + date := now.Format("2006-01-02") + day, err := h.DB.GetOrCreateDay(user.ID, date) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error updating break threshold") + return + } + day.MinBreakThreshold = int64(mins) * 60 + if err := h.DB.UpdateDay(day); err != nil { + h.sendText(ctx, msg.Chat.ID, "Error saving break threshold") + return + } + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Break threshold set to %d minutes", mins)) +} + // settingsCallback shows the settings inline menu. func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { h.answerCb(ctx, callbackID) diff --git a/internal/bot/summary.go b/internal/bot/summary.go new file mode 100644 index 0000000..3330669 --- /dev/null +++ b/internal/bot/summary.go @@ -0,0 +1,139 @@ +package bot + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/go-telegram/bot/models" + + "worktimeBot/internal/db" +) + +// handleSummaryMsg parses /summary [YYYY-MM] and sends a monthly summary. +func (h *Handler) handleSummaryMsg(ctx context.Context, msg *models.Message) { + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(ctx, msg.Chat.ID, "Error loading profile") + return + } + loc := loadLocation(user.Timezone) + now := time.Now().In(loc) + + args := strings.Fields(msg.Text) + if len(args) > 0 && args[0][0] == '/' { + args = args[1:] + } + + var calY, calM int + if len(args) >= 1 { + if _, err := fmt.Sscanf(args[0], "%d-%d", &calY, &calM); err != nil || calM < 1 || calM > 12 { + h.sendText(ctx, msg.Chat.ID, "Usage: /summary [YYYY-MM] (e.g. /summary 2026-06)") + return + } + } else { + calY, calM = now.Year(), int(now.Month()) + switch user.Calendar { + case "jalali": + calY, calM, _ = gregorianToJalali(calY, calM, now.Day()) + case "hijri": + calY, calM, _ = gregorianToHijri(calY, calM, now.Day()) + } + } + + startStr, endStr := monthGregorianRange(user.Calendar, calY, calM) + summary := h.buildMonthlySummary(user, calY, calM, startStr, endStr, loc) + h.sendText(ctx, msg.Chat.ID, summary) +} + +// buildMonthlySummary returns a formatted summary for a calendar month. +func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, endStr string, loc *time.Location) string { + days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr) + if err != nil { + return "Error loading days" + } + + title := formatMonthTitle(user.Calendar, calY, calM) + + totalWork := int64(0) + totalBreak := int64(0) + dayOffCount := 0 + workDayCount := 0 + dayDetails := []string{} + + for _, day := range days { + events, err := h.DB.EventsForDayByDayID(day.ID) + if err != nil || len(events) == 0 { + continue + } + 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) + + dayLabel := formatDateForCalendar(day.Date, user.Calendar) + if day.IsDayOff { + dayOffCount++ + dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel)) + } else if totals.TotalSeconds > 0 { + workDayCount++ + totalWork += totals.TotalSeconds + totalBreak += totals.BreakSeconds + dayDetails = append(dayDetails, fmt.Sprintf(" %s - %s worked", dayLabel, formatDuration(totals.TotalSeconds))) + } + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("Summary for %s\n", title)) + b.WriteString(fmt.Sprintf("Work days: %d\n", workDayCount)) + if dayOffCount > 0 { + b.WriteString(fmt.Sprintf("Days off: %d\n", dayOffCount)) + } + b.WriteString(fmt.Sprintf("Total: %s worked", formatDuration(totalWork))) + if totalBreak > 0 { + b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak))) + } + b.WriteString("\n\nDetails:\n") + for _, d := range dayDetails { + b.WriteString(d) + b.WriteString("\n") + } + return b.String() +} + +// computeWeekTotals returns the total work seconds for the current week (Mon-Sun containing today). +func (h *Handler) computeWeekTotals(user *db.User, loc *time.Location) int64 { + now := time.Now().In(loc) + weekday := now.Weekday() + if weekday == time.Sunday { + weekday = 7 + } + monday := now.AddDate(0, 0, -int(weekday-time.Monday)) + sunday := monday.AddDate(0, 0, 6) + + startStr := monday.Format("2006-01-02") + endStr := sunday.Format("2006-01-02") + + days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr) + if err != nil { + return 0 + } + + var total int64 + for _, day := range days { + if day.IsDayOff { + continue + } + events, err := h.DB.EventsForDayByDayID(day.ID) + if err != nil || len(events) == 0 { + continue + } + 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) + total += totals.TotalSeconds + } + return total +} diff --git a/internal/bot/summary_test.go b/internal/bot/summary_test.go new file mode 100644 index 0000000..1981b02 --- /dev/null +++ b/internal/bot/summary_test.go @@ -0,0 +1,29 @@ +package bot + +import ( + "testing" +) + +func TestFormatMonthTitle(t *testing.T) { + tests := []struct { + cal string + year, m int + want string + }{ + {"gregorian", 2026, 6, "June 2026"}, + {"gregorian", 2024, 1, "January 2024"}, + {"jalali", 1404, 1, "Farvardin 1404"}, + {"jalali", 1405, 12, "Esfand 1405"}, + {"hijri", 1447, 1, "Muharram 1447"}, + {"hijri", 1447, 12, "Dhu al-Hijjah 1447"}, + } + for _, tc := range tests { + t.Run(tc.want, func(t *testing.T) { + got := formatMonthTitle(tc.cal, tc.year, tc.m) + if got != tc.want { + t.Errorf("formatMonthTitle(%q, %d, %d) = %q, want %q", + tc.cal, tc.year, tc.m, got, tc.want) + } + }) + } +}