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:
12
README.md
12
README.md
@@ -53,16 +53,22 @@ 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) |
|
||||
| `/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 <text>` | Set note for an event at a specific time |
|
||||
| `/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 |
|
||||
|
||||
## 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 <minutes>` to adjust per day (0-480).
|
||||
|
||||
```
|
||||
09:00 Clock In → work starts
|
||||
|
||||
@@ -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 <text> - 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 <name> - Set your timezone (e.g. Asia/Tehran)
|
||||
/setbreak <minutes> - Set minimum break threshold in minutes
|
||||
|
||||
Dates use your calendar type (Gregorian/Jalali/Hijri).
|
||||
Buttons on the main menu also work.`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <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.
|
||||
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
|
||||
139
internal/bot/summary.go
Normal file
139
internal/bot/summary.go
Normal 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
|
||||
}
|
||||
29
internal/bot/summary_test.go
Normal file
29
internal/bot/summary_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user