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 <minutes> 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
This commit is contained in:
2026-06-24 20:39:51 +03:30
parent 323c1bc010
commit 834c318271
6 changed files with 255 additions and 15 deletions

View File

@@ -53,16 +53,22 @@ The history view, export month picker, and date displays all adapt to the user's
### Commands ### Commands
| Command | Action | | Command | Action |
| --------------------- | ------------------------------------------------------- | | --------------------------- | ----------------------------------------------------------- |
| `/start` | Show inline menu | | `/start` | Show inline menu |
| `/clockin` | Record clock-in | | `/clockin` | Record clock-in |
| `/clockout` | Record clock-out | | `/clockout` | Record clock-out |
| `/remote` | Toggle remote/onsite | | `/remote` | Toggle remote/onsite |
| `/report` | Today's work & break summary | | `/report` | Today's work & break summary |
| `/export` | Export this month (optional `YYYY-MM` in your calendar) | | `/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 | | `/dayoff` | Toggle today as day off |
| `/edit YYYY-MM-DD` | View/edit events for a specific date | | `/edit YYYY-MM-DD` | View/edit events for a specific date |
| `/note [DATE] HH:MM <text>` | Set note for an event at a specific time |
| `/settimezone <IANA>` | Set timezone (e.g. `Asia/Tehran`) | | `/settimezone <IANA>` | Set timezone (e.g. `Asia/Tehran`) |
| `/setbreak <minutes>` | 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 | | `/settings` | Open settings menu |
## Daily Report ## Daily Report
@@ -71,7 +77,7 @@ A daily summary is sent automatically at the user's configured report time (defa
## How Break Time Works ## 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 <minutes>` to adjust per day (0-480).
``` ```
09:00 Clock In → work starts 09:00 Clock In → work starts

View File

@@ -106,6 +106,9 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] {
return return
} }
if msg.Text == "" {
return
}
user, err := h.getOrCreateUser(msg.Chat.ID) user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil { if err != nil {
return return
@@ -122,7 +125,7 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
delete(h.pendingNotes, msg.Chat.ID) delete(h.pendingNotes, msg.Chat.ID)
} }
h.mu.Unlock() h.mu.Unlock()
if hasPN && msg.Text != "" && msg.Text[0] != '/' { if hasPN && msg.Text[0] != '/' {
loc := loadLocation(user.Timezone) loc := loadLocation(user.Timezone)
event, err := h.getEventByID(user.ID, pn.eventID) event, err := h.getEventByID(user.ID, pn.eventID)
if err == nil { if err == nil {
@@ -135,7 +138,17 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
return 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": case "/start":
h.handleStart(ctx, msg) h.handleStart(ctx, msg)
case "/help": case "/help":
@@ -166,6 +179,10 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
h.handleSetTimezone(ctx, msg) h.handleSetTimezone(ctx, msg)
case "/note": case "/note":
h.handleNoteMsg(ctx, msg) h.handleNoteMsg(ctx, msg)
case "/summary":
h.handleSummaryMsg(ctx, msg)
case "/setbreak":
h.handleSetBreakMsg(ctx, msg)
default: default:
h.sendText(ctx, msg.Chat.ID, "Unknown command") 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 /history - View calendar history
/edit YYYY-MM-DD - Edit a specific day /edit YYYY-MM-DD - Edit a specific day
/note [YYYY-MM-DD] HH:MM <text> - Set note for an event /note [YYYY-MM-DD] HH:MM <text> - Set note for an event
/summary [YYYY-MM] - Show monthly summary (current month if omitted)
/reporttoggle - Enable/disable auto report /reporttoggle - Enable/disable auto report
/setreporttime HH:MM - Set daily report time /setreporttime HH:MM - Set daily report time
/setaccent - Select accent color /setaccent - Select accent color
/settimezone <name> - Set your timezone (e.g. Asia/Tehran) /settimezone <name> - Set your timezone (e.g. Asia/Tehran)
/setbreak <minutes> - Set minimum break threshold in minutes
Dates use your calendar type (Gregorian/Jalali/Hijri). Dates use your calendar type (Gregorian/Jalali/Hijri).
Buttons on the main menu also work.` Buttons on the main menu also work.`

View File

@@ -49,6 +49,16 @@ func (h *Handler) buildStatusText(chatID int64) string {
text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds)) 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:" text += "\n\nChoose an action:"
return text return text
} }

View File

@@ -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"))) 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 <minutes> (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. // settingsCallback shows the settings inline menu.
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID) h.answerCb(ctx, callbackID)

139
internal/bot/summary.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
})
}
}