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
|
### 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
|
||||||
|
|||||||
@@ -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.`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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