fix: resolve go vet error and dayStart logic in ComputeDailyTotals

- Fix 'string(d)' int-to-rune conversion in rpg_test.go by using fmt.Sprintf
- Fix ComputeDailyTotals dayStart logic: initial state should be StateWorking
  when dayStart > 0, correctly counting work from dayStart to first out event
- Update README project structure to reflect new domain/handler/repo layout
- Implement missing achievements: rpg_pioneer (on RPG enable) and
  salary_setter (on first rate set)
- Add tryUnlockAchievement helper to Handler
This commit is contained in:
2026-06-25 09:54:22 +03:30
parent 3835b002a7
commit d15ed46066
45 changed files with 4947 additions and 5430 deletions

View File

@@ -147,24 +147,35 @@ Four color themes are available in settings: **Ocean**, **Beach**, **Rose**, **C
│ ├── main.go Entrypoint, polling/webhook, scheduler
│ └── webhook.go Webhook mode with TLS support
├── internal/
│ ├── bot/
│ │ ├── handlers.go Message/callback routing, views, menu builders
│ │ ├── clock.go Clock in/out, day off, report
│ ├── domain/ Pure business logic, no external dependencies
│ │ ├── types.go Core domain types (User, Event, Day, RPGStats, ...)
│ │ ├── constants.go App-wide constants and thresholds
│ │ ├── dateutil.go Gregorian/Jalali/Hijri calendar conversions
│ │ ├── totals.go Break/work computation state machine
│ │ ├── rpg.go XP curves, levels, streaks
│ │ ├── burnout.go Burnout trend computation
│ │ ├── salary.go Salary estimation, currency helpers
│ │ └── sanitize.go Input sanitization helpers
│ ├── handler/ Telegram bot handlers (one per feature)
│ │ ├── handler.go Core handler, routing, helpers
│ │ ├── clock.go Clock in/out, day off, report
│ │ ├── calendar.go History view and inline event editing
│ │ ├── export.go Excel (.xlsx) report generation
│ │ ├── dateutil.go Gregorian/Jalali/Hijri conversions & calendars
│ │ ├── rpg.go XP, levels, streaks, achievements
│ │ ├── rpg.go RPG stats, achievements, aggregates
│ │ ├── league.go WorkTime League leaderboard
│ │ ├── salary.go Salary estimation, rate/currency commands
│ │ ├── burnout.go Burnout assessment model
│ │ ├── salary.go Salary estimation view
│ │ ├── burnout.go Burnout assessment view
│ │ ├── settings.go Settings menu with inline pickers
│ │ ├── summary.go Monthly summary
│ │ ├── report.go Daily report builder
│ │ ── sanitize.go Input sanitization helpers
└── db/
── store.go SQLite store (data access layer)
└── migrations/ Goose-managed SQL migrations
│ │ ├── report.go Daily report builder, status text
│ │ ── note.go Note command, edit command
│ ├── worktype.go Work type selection
── keyboard.go Inline keyboard builders
└── repo/ Data access layer
│ ├── interface.go Repository interface
│ ├── store.go SQLite implementation
│ └── migrations.go Goose-managed migration runner
│ └── migrations/ SQL migration files
│ ├── 001_init.sql Initial schema
│ └── 002_features.sql RPG, achievements, user_settings
├── .gitea/workflows/

View File

@@ -14,8 +14,8 @@ import (
"github.com/go-telegram/bot/models"
"github.com/joho/godotenv"
"worktimeBot/internal/bot"
"worktimeBot/internal/db"
"worktimeBot/internal/handler"
"worktimeBot/internal/repo"
)
func main() {
@@ -23,12 +23,13 @@ func main() {
AddSource: true,
Level: slog.LevelInfo,
})))
godotenv.Load()
token := os.Getenv("BOT_TOKEN")
dbPath := getEnvDefault("DB_PATH", "db.sqlite3")
dbStore, err := db.NewStore(dbPath)
store, err := repo.NewStore(dbPath)
if err != nil {
slog.Error("failed to open database", "path", dbPath, "error", err)
os.Exit(1)
@@ -36,7 +37,7 @@ func main() {
allowedUsers := parseAllowedUsers(os.Getenv("BOT_ALLOWED_USERS"))
var h *bot.Handler
var h *handler.Handler
dispatch := func(ctx context.Context, b *tgbot.Bot, update *models.Update) {
if update.Message != nil {
@@ -53,7 +54,7 @@ func main() {
os.Exit(1)
}
h = bot.NewHandler(b, dbStore, allowedUsers)
h = handler.NewHandler(b, store, allowedUsers)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
@@ -64,7 +65,7 @@ func main() {
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
slog.Warn("failed to delete webhook", "error", err)
}
go dailyReportScheduler(ctx, h, dbStore)
go dailyReportScheduler(ctx, h, store)
slog.Info("bot started",
"allowed_users", len(allowedUsers),
"mode", "polling",
@@ -72,11 +73,11 @@ func main() {
b.Start(ctx)
}
dbStore.Close()
store.Close()
}
// dailyReportScheduler runs a periodic ticker that triggers daily report checks every 30 seconds.
func dailyReportScheduler(ctx context.Context, h *bot.Handler, store *db.Store) {
func dailyReportScheduler(ctx context.Context, h *handler.Handler, store repo.Repository) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
@@ -91,7 +92,7 @@ func dailyReportScheduler(ctx context.Context, h *bot.Handler, store *db.Store)
}
// checkDailyReports iterates all users and sends a daily report to those whose report time has arrived.
func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) {
func checkDailyReports(ctx context.Context, h *handler.Handler, store repo.Repository) {
users, err := store.GetAllUsers()
if err != nil {
slog.Error("report scheduler: get users", "error", err)
@@ -131,6 +132,7 @@ func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) {
"user_id", u.ID, "chat_id", u.ChatID, "timezone", u.Timezone,
)
h.SendDailyReport(ctx, u.ID, u.ChatID)
store.MarkReportSent(u.ID, today)
}
}

View File

@@ -8,11 +8,11 @@ import (
tgbot "github.com/go-telegram/bot"
"worktimeBot/internal/bot"
"worktimeBot/internal/handler"
)
// startWebhook registers the webhook with Telegram and starts the HTTPS/HTTP listener loop.
func startWebhook(ctx context.Context, b *tgbot.Bot, h *bot.Handler, webhookURL string) {
func startWebhook(ctx context.Context, b *tgbot.Bot, h *handler.Handler, webhookURL string) {
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
slog.Warn("failed to delete webhook", "error", err)
}

View File

@@ -1,328 +0,0 @@
package bot
import (
"fmt"
"math"
"strings"
"time"
"worktimeBot/internal/db"
)
// Burnout scoring model
// ======================
// Total score out of 100, computed as a weighted sum of signals:
//
// 1. Consecutive workdays (0-25 pts)
// Measures how many days the user has worked without a break.
// 0 days: 0, 1-3: 5, 4-6: 15, 7+: 25
//
// 2. Long hours today (0-20 pts)
// Measures daily work duration.
// <8h: 0, 8-10h: 10, 10-12h: 15, 12h+: 20
//
// 3. Reduced breaks (0-15 pts)
// Measures break ratio (break / (work+break)).
// >20%: 0, 10-20%: 5, 5-10%: 10, <5%: 15
//
// 4. Weekend work (0-15 pts)
// Working on Saturday or Sunday in user's timezone.
// Not weekend: 0, Weekend: 15
//
// 5. Work trend (0-15 pts)
// Compares last 3 days to the 3 before that.
// Decreasing: 0, Stable: 5, Increasing: 15
//
// 6. Late-night work (0-10 pts)
// Last event after typical working hours.
// Before 20:00: 0, 20:00-22:00: 5, After 22:00: 10
//
// Total -> Concern level:
// 0-20: Healthy
// 21-40: Mild concern
// 41-60: Moderate concern
// 61+: High concern
// BurnoutLevel represents the severity of burnout indicators.
type BurnoutLevel int
const (
BurnoutHealthy BurnoutLevel = 0
BurnoutMild BurnoutLevel = 1
BurnoutModerate BurnoutLevel = 2
BurnoutHigh BurnoutLevel = 3
)
func (b BurnoutLevel) String() string {
switch b {
case BurnoutHealthy:
return "Healthy"
case BurnoutMild:
return "Mild concern"
case BurnoutModerate:
return "Moderate concern"
case BurnoutHigh:
return "High concern"
default:
return "Unknown"
}
}
// BurnoutResult holds the full burnout assessment.
type BurnoutResult struct {
Score int
Level BurnoutLevel
ConsecutivePts int
LongHoursPts int
BreakRatioPts int
WeekendPts int
TrendPts int
LateNightPts int
ConsecutiveDays int
WorkSeconds int64
BreakSeconds int64
TrendDirection string
}
// assessBurnout computes a burnout assessment for a user.
func (h *Handler) assessBurnout(userID int64, loc *time.Location) (*BurnoutResult, error) {
now := time.Now().In(loc)
today := now.Format(DateLayout)
result := &BurnoutResult{}
// 1. Consecutive workdays
stats, err := h.DB.GetOrCreateRPGStats(userID)
if err == nil {
result.ConsecutiveDays = stats.CurrentStreak
}
switch {
case result.ConsecutiveDays >= 7:
result.ConsecutivePts = 25
case result.ConsecutiveDays >= 4:
result.ConsecutivePts = 15
case result.ConsecutiveDays >= 1:
result.ConsecutivePts = 5
}
// 2. Long hours today
todayEvents, _ := h.DB.EventsForDayByDate(userID, today)
if len(todayEvents) > 0 {
day, _ := h.DB.GetDay(userID, today)
if day != nil {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
dayEnd := now.Unix()
totals := ComputeDailyTotals(todayEvents, day.MinBreakThreshold, dayStart, dayEnd)
result.WorkSeconds = totals.TotalSeconds
result.BreakSeconds = totals.BreakSeconds
workHours := float64(totals.TotalSeconds) / secondsPerHour
switch {
case workHours >= 12:
result.LongHoursPts = 20
case workHours >= 10:
result.LongHoursPts = 15
case workHours >= 8:
result.LongHoursPts = 10
}
// 3. Reduced breaks
total := totals.TotalSeconds + totals.BreakSeconds
if total > 0 {
breakRatio := float64(totals.BreakSeconds) / float64(total) * 100
switch {
case breakRatio < 5:
result.BreakRatioPts = 15
case breakRatio < 10:
result.BreakRatioPts = 10
case breakRatio < 20:
result.BreakRatioPts = 5
}
}
// 6. Late-night work
if len(todayEvents) > 0 {
lastEvent := todayEvents[len(todayEvents)-1]
lt := time.Unix(lastEvent.OccurredAt, 0).In(loc)
minutes := lt.Hour()*60 + lt.Minute()
switch {
case minutes >= 22*60:
result.LateNightPts = 10
case minutes >= 20*60:
result.LateNightPts = 5
}
}
}
}
// 4. Weekend work
weekday := now.Weekday()
if weekday == time.Saturday || weekday == time.Sunday {
if result.WorkSeconds > 0 {
result.WeekendPts = 15
}
}
// 5. Work trend (last 3 days vs 3 before that)
result.TrendPts, result.TrendDirection = computeTrend(h.DB, userID, today, loc)
// Total
result.Score = result.ConsecutivePts + result.LongHoursPts + result.BreakRatioPts +
result.WeekendPts + result.TrendPts + result.LateNightPts
switch {
case result.Score >= 61:
result.Level = BurnoutHigh
case result.Score >= 41:
result.Level = BurnoutModerate
case result.Score >= 21:
result.Level = BurnoutMild
default:
result.Level = BurnoutHealthy
}
return result, nil
}
// computeTrend compares average work time of most recent 3 days vs the 3 before those.
func computeTrend(store *db.Store, userID int64, today string, loc *time.Location) (pts int, direction string) {
t, err := time.Parse(DateLayout, today)
if err != nil {
return 0, "unknown"
}
var recent, older [3]int64
for i := 0; i < 3; i++ {
d := t.AddDate(0, 0, -(i + 1)).Format(DateLayout)
recent[2-i] = dayWorkSeconds(store, userID, d, loc)
}
for i := 0; i < 3; i++ {
d := t.AddDate(0, 0, -(i + 4)).Format(DateLayout)
older[2-i] = dayWorkSeconds(store, userID, d, loc)
}
var recentAvg, olderAvg float64
for _, v := range recent {
recentAvg += float64(v)
}
for _, v := range older {
olderAvg += float64(v)
}
recentAvg /= 3.0
olderAvg /= 3.0
if olderAvg < 1 {
if recentAvg > 0 {
return 15, "new"
}
return 5, "stable"
}
changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100)
if recentAvg > olderAvg*1.2 {
return 15, fmt.Sprintf("+%.0f%%", changePct)
}
if olderAvg > recentAvg*1.2 {
return 0, fmt.Sprintf("%.0f%%", changePct)
}
return 5, "stable"
}
// computeTrendFromAvgs is a pure version of the trend computation for testing.
func computeTrendFromAvgs(recentAvg, olderAvg float64) (pts int, direction string) {
if olderAvg < 1 {
if recentAvg > 0 {
return 15, "new"
}
return 5, "stable"
}
changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100)
if recentAvg > olderAvg*1.2 {
return 15, fmt.Sprintf("+%.0f%%", changePct)
}
if olderAvg > recentAvg*1.2 {
return 0, fmt.Sprintf("%.0f%%", changePct)
}
return 5, "stable"
}
func dayWorkSeconds(store *db.Store, userID int64, date string, loc *time.Location) int64 {
events, err := store.EventsForDayByDate(userID, date)
if err != nil || len(events) == 0 {
return 0
}
day, err := store.GetDay(userID, date)
if err != nil || day == nil {
return 0
}
y, m, d := parseGregorianDateKey(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)
return totals.TotalSeconds
}
// buildBurnoutText returns a formatted burnout assessment.
func (h *Handler) buildBurnoutText(userID int64, loc *time.Location) string {
res, err := h.assessBurnout(userID, loc)
if err != nil {
return "Burnout assessment unavailable."
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Burnout Assessment\nLevel: %s (%d/100)\n\n", res.Level, res.Score))
if res.ConsecutiveDays > 0 {
b.WriteString(fmt.Sprintf(" Consecutive workdays: %d days (%d pts)\n", res.ConsecutiveDays, res.ConsecutivePts))
}
if res.WorkSeconds > 0 {
wh := float64(res.WorkSeconds) / secondsPerHour
b.WriteString(fmt.Sprintf(" Today's work: %.1f hours (%d pts)\n", wh, res.LongHoursPts))
}
if res.BreakSeconds > 0 {
br := float64(res.BreakSeconds)
total := float64(res.WorkSeconds + res.BreakSeconds)
ratio := br / total * 100
if ratio > 100 {
ratio = 100
}
b.WriteString(fmt.Sprintf(" Break ratio: %.0f%% (%d pts)\n", ratio, res.BreakRatioPts))
}
if res.WeekendPts > 0 {
b.WriteString(fmt.Sprintf(" Weekend work: yes (%d pts)\n", res.WeekendPts))
}
b.WriteString(fmt.Sprintf(" Work trend: %s (%d pts)\n", res.TrendDirection, res.TrendPts))
if res.LateNightPts > 0 {
b.WriteString(fmt.Sprintf(" Late-night work: yes (%d pts)\n", res.LateNightPts))
}
b.WriteString("\nRecommendations:\n")
switch res.Level {
case BurnoutHealthy:
b.WriteString(" Keep up the balanced routine!")
case BurnoutMild:
b.WriteString(" Consider taking short breaks throughout the day.")
b.WriteString("\n Make sure to disconnect after work hours.")
case BurnoutModerate:
b.WriteString(" Consider taking a day off soon.")
b.WriteString("\n Review your workload distribution.")
b.WriteString("\n Ensure you are taking adequate breaks.")
case BurnoutHigh:
b.WriteString(" Strongly consider taking time off.")
b.WriteString("\n Your workload patterns indicate potential strain.")
b.WriteString("\n Please prioritize rest and recovery.")
}
return b.String()
}
// burnoutAssessment returns the burnout assessment text.
func (h *Handler) burnoutAssessment(chatID int64) (string, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return "", err
}
loc := loadLocation(user.Timezone)
return h.buildBurnoutText(user.ID, loc), nil
}

View File

@@ -1,142 +0,0 @@
package bot
import (
"database/sql"
"errors"
"fmt"
"time"
)
// clockIn records a clock-in event for today.
// Returns the formatted time or an error if already clocked in today.
func (h *Handler) clockIn(chatID int64) (string, error) {
user, day, err := h.getUserToday(chatID)
if err != nil {
return "", err
}
last, err := h.DB.GetLastEvent(user.ID)
if err != nil && err != sql.ErrNoRows {
return "", err
}
if last != nil && last.EventType == "in" {
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
lastTime := time.Unix(last.OccurredAt, 0).In(loc)
if lastTime.Format(DateLayout) == now.Format(DateLayout) {
return "", errors.New("already clocked in")
}
}
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
wt := day.CurrentWorkTypeID
if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
return "", err
}
return fmt.Sprintf("clocked in at %s", now.Format(TimeLayout)), nil
}
// clockOut records a clock-out event for today.
// Returns the formatted time or an error if not clocked in.
// Also awards XP and updates streaks if RPG is enabled.
func (h *Handler) clockOut(chatID int64) (string, error) {
user, day, err := h.getUserToday(chatID)
if err != nil {
return "", err
}
last, err := h.DB.GetLastEvent(user.ID)
if err != nil {
return "", err
}
if last == nil {
return "", errors.New("no clock-in found — start with /clockin first")
}
if last.EventType == "out" {
return "", errors.New("already clocked out")
}
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
return "", err
}
text := fmt.Sprintf("clocked out at %s", now.Format(TimeLayout))
// Award XP and update streaks if RPG enabled
st, err := h.DB.GetOrCreateSettings(user.ID)
if err == nil && st.RPGEnabled {
sessionWork := now.Unix() - last.OccurredAt
today := now.Format(DateLayout)
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
stats, err := h.DB.GetOrCreateRPGStats(user.ID)
if err == nil {
h.updateStreak(stats, user.ID, today)
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
totalHours := float64(stats.TotalWorkSeconds) / secondsPerHour
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
if leveledUp {
text += "\nLevel up!"
}
for _, a := range unlocked {
text += fmt.Sprintf("\nAchievement: %s", a)
}
}
}
return text, nil
}
// dayOff toggles today as a day off.
func (h *Handler) dayOff(chatID int64) (string, error) {
user, day, err := h.getUserToday(chatID)
if err != nil {
return "", err
}
if day.IsDayOff {
if err := h.DB.RemoveDayOff(user.ID, day.Date); err != nil {
return "", err
}
return "day off removed", nil
}
if err := h.DB.SetDayOff(user.ID, day.Date, ""); err != nil {
return "", err
}
return "today marked as day off", nil
}
// report returns today's work summary via buildDailyReport.
func (h *Handler) report(chatID int64) (string, error) {
user, day, err := h.getUserToday(chatID)
if err != nil {
return "", err
}
events, err := h.DB.EventsForDayByDayID(day.ID)
if err != nil {
return "", err
}
return h.buildDailyReport(user, day, events), nil
}
// toggleReport enables or disables the automatic daily report.
func (h *Handler) toggleReport(chatID int64) (string, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return "", err
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return "", err
}
st.ReportEnabled = !st.ReportEnabled
if err := h.DB.UpdateSettings(st); err != nil {
return "", err
}
if st.ReportEnabled {
return "daily report enabled", nil
}
return "daily report disabled", nil
}

View File

@@ -1,354 +0,0 @@
// Package bot implements the Telegram bot logic: message routing, calendar rendering,
// time tracking (multi-calendar), Excel export, and inline editing of events.
package bot
import (
"fmt"
"time"
)
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
}
// JDN for March 21, 622 CE (Jalali epoch: 1 Farvardin 1)
// jalaliEpochJDN is the Julian Day Number for 1 Farvardin 1 (March 21, 622 CE).
const jalaliEpochJDN = 1948320
// gregorianToJDN converts a Gregorian date to a Julian Day Number.
func gregorianToJDN(gy, gm, gd int) int {
a := (14 - gm) / 12
y := gy + 4800 - a
m := gm + 12*a - 3
return gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
}
// jdnToGregorian converts a Julian Day Number to a Gregorian date (year, month, day).
func jdnToGregorian(jdn int) (int, int, int) {
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
}
// gregorianToJalali converts a Gregorian date to Jalali (Persian/Solar Hijri).
func gregorianToJalali(gy, gm, gd int) (int, int, int) {
jdn := gregorianToJDN(gy, gm, gd)
days := jdn - jalaliEpochJDN
if days < 0 {
return 0, 0, 0
}
jy := 1
for {
yearDays := 365
if isJalaliLeap(jy) {
yearDays = 366
}
if days < yearDays {
break
}
days -= yearDays
jy++
}
jm := 1
for mm := 0; mm < 12; mm++ {
md := monthLenJalali(jy, mm+1)
if days < md {
break
}
days -= md
jm++
}
jd := days + 1
return jy, jm, jd
}
// jalaliToGregorian converts a Jalali (Persian/Solar Hijri) date to Gregorian.
func jalaliToGregorian(jy, jm, jd int) (int, int, int) {
days := 0
for y := 1; y < jy; y++ {
if isJalaliLeap(y) {
days += 366
} else {
days += 365
}
}
for m := 1; m < jm; m++ {
days += monthLenJalali(jy, m)
}
days += jd - 1
jdn := jalaliEpochJDN + days
return jdnToGregorian(jdn)
}
// hijriToGregorian converts a Hijri (Islamic) date to Gregorian.
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
}
// gregorianToHijri converts a Gregorian date to Hijri (Islamic).
func gregorianToHijri(gy, gm, gd int) (int, int, int) {
jdn := gregorianToJDN(gy, gm, gd)
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=Sat,1=Sun,2=Mon,3=Tue,4=Wed,5=Thu,6=Fri
if gm < 3 {
gm += 12
gy--
}
return (gd + (13*(gm+1))/5 + gy + gy/4 - gy/100 + gy/400) % 7
}
// calDay represents a single day cell in a calendar month view.
type calDay struct {
dayNum int
date string // YYYY-MM-DD gregorian key
}
// calMonth represents a rendered calendar month with title, weekday headers, and day cells.
type calMonth struct {
title string
weekDays []string
days []calDay
}
// buildCalendarMonth builds a calMonth struct for the given calendar type, year and month.
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)
startOffset := dow
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 = 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 + 5) % 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)
startOffset := (dow + 5) % 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}
}
// parseGregorianDateKey parses a "YYYY-MM-DD" string into year, month, day integers.
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
}
// formatDateForCalendar converts a Gregorian YYYY-MM-DD date string into the target calendar representation.
func formatDateForCalendar(date, calType string) string {
y, m, d := parseGregorianDateKey(date)
switch calType {
case "jalali":
jy, jm, jd := gregorianToJalali(y, m, d)
return fmt.Sprintf("%04d-%02d-%02d", jy, jm, jd)
case "hijri":
hy, hm, hd := gregorianToHijri(y, m, d)
return fmt.Sprintf("%04d-%02d-%02d", hy, hm, hd)
default:
return date
}
}
// userDateToGregorian converts a YYYY-MM-DD string in the user's calendar
// to the corresponding Gregorian YYYY-MM-DD string.
func userDateToGregorian(dateStr, cal string) string {
y, m, d := parseGregorianDateKey(dateStr)
switch cal {
case "jalali":
y, m, d = jalaliToGregorian(y, m, d)
case "hijri":
y, m, d = hijriToGregorian(y, m, d)
}
return fmt.Sprintf("%04d-%02d-%02d", y, m, d)
}
// monthGregorianRange returns the Gregorian start and end date strings
// for a given calendar month (cal=gregorian|jalali|hijri).
func monthGregorianRange(cal string, year, month int) (start, end string) {
switch cal {
case "jalali":
gy, gm, gd := jalaliToGregorian(year, month, 1)
start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
lastDay := monthLenJalali(year, month)
gy, gm, gd = jalaliToGregorian(year, month, lastDay)
end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
case "hijri":
gy, gm, gd := hijriToGregorian(year, month, 1)
start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
lastDay := monthLenHijri(year, month)
gy, gm, gd = hijriToGregorian(year, month, lastDay)
end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
default:
start = fmt.Sprintf("%04d-%02d-%02d", year, month, 1)
lastDay := monthLenGreg(year, month)
end = fmt.Sprintf("%04d-%02d-%02d", year, month, lastDay)
}
return
}
// loadLocation loads a timezone, falling back to UTC on error.
func loadLocation(tz string) *time.Location {
loc, err := time.LoadLocation(tz)
if err != nil {
return time.UTC
}
return loc
}

View File

@@ -1,247 +0,0 @@
package bot
import (
"testing"
)
func TestGregorianToJDN_RoundTrip(t *testing.T) {
cases := []struct{ y, m, d int }{
{2024, 1, 1},
{2024, 12, 31},
{1970, 1, 1},
{1, 1, 1},
{2024, 2, 29},
{1900, 2, 28},
{2000, 2, 29},
}
for _, c := range cases {
jdn := gregorianToJDN(c.y, c.m, c.d)
y, m, d := jdnToGregorian(jdn)
if y != c.y || m != c.m || d != c.d {
t.Errorf("round-trip %04d-%02d-%02d -> JDN %d -> %04d-%02d-%02d", c.y, c.m, c.d, jdn, y, m, d)
}
}
}
func TestGregorianToJalali_RoundTrip(t *testing.T) {
cases := []struct{ gy, gm, gd int }{
{2024, 3, 20},
{2024, 3, 21},
{2024, 9, 22},
{2025, 3, 20},
{1979, 2, 11},
{622, 3, 22},
{2024, 12, 31},
{2024, 6, 21},
{2024, 1, 1},
}
for _, c := range cases {
jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd)
if jy == 0 {
t.Errorf("gregorianToJalali(%d, %d, %d) returned 0", c.gy, c.gm, c.gd)
continue
}
gy2, gm2, gd2 := jalaliToGregorian(jy, jm, jd)
if gy2 != c.gy || gm2 != c.gm || gd2 != c.gd {
t.Errorf("round-trip Gregorian %04d-%02d-%02d -> Jalali %04d-%02d-%02d -> Gregorian %04d-%02d-%02d",
c.gy, c.gm, c.gd, jy, jm, jd, gy2, gm2, gd2)
}
}
}
func TestGregorianToHijri_RoundTrip(t *testing.T) {
cases := []struct{ gy, gm, gd int }{
{2024, 3, 21},
{2024, 1, 1},
{2024, 12, 31},
{1979, 2, 11},
{622, 7, 16},
{2024, 7, 7},
{2024, 6, 21},
{2000, 1, 1},
}
for _, c := range cases {
hy, hm, hd := gregorianToHijri(c.gy, c.gm, c.gd)
if hy == 0 {
t.Errorf("gregorianToHijri(%d, %d, %d) returned 0", c.gy, c.gm, c.gd)
continue
}
gy2, gm2, gd2 := hijriToGregorian(hy, hm, hd)
diff := (gy2-c.gy)*365 + (gm2-c.gm)*30 + (gd2 - c.gd)
if diff < 0 {
diff = -diff
}
if diff > 1 {
t.Errorf("round-trip off by %d days: Gregorian %04d-%02d-%02d -> Hijri %04d-%02d-%02d -> Gregorian %04d-%02d-%02d",
diff, c.gy, c.gm, c.gd, hy, hm, hd, gy2, gm2, gd2)
}
}
}
func TestIsLeapYear(t *testing.T) {
gregLeaps := map[int]bool{
2000: true, 2020: true, 2024: true, 1900: false, 2100: false, 2023: false,
}
for y, want := range gregLeaps {
if got := isGregorianLeap(y); got != want {
t.Errorf("isGregorianLeap(%d) = %v, want %v", y, got, want)
}
}
jalaliLeaps := map[int]bool{
1: true, 5: true, 9: true, 13: true, 17: true, 22: true, 26: true, 30: true,
2: false, 33: false, 1403: true,
}
for y, want := range jalaliLeaps {
if got := isJalaliLeap(y); got != want {
t.Errorf("isJalaliLeap(%d) = %v, want %v", y, got, want)
}
}
hijriLeaps := map[int]bool{
2: true, 5: true, 7: true, 10: true, 13: true, 16: true,
18: true, 21: true, 24: true, 27: true, 29: true,
1: false, 3: false, 30: false,
}
for y, want := range hijriLeaps {
if got := isHijriLeap(y); got != want {
t.Errorf("isHijriLeap(%d) = %v, want %v", y, got, want)
}
}
}
func TestMonthLengths(t *testing.T) {
if got := monthLenGreg(2024, 2); got != 29 {
t.Errorf("monthLenGreg(2024, 2) = %d, want 29", got)
}
if got := monthLenGreg(2023, 2); got != 28 {
t.Errorf("monthLenGreg(2023, 2) = %d, want 28", got)
}
if got := monthLenGreg(2024, 1); got != 31 {
t.Errorf("monthLenGreg(2024, 1) = %d, want 31", got)
}
if got := monthLenJalali(1, 12); got != 30 {
t.Errorf("monthLenJalali(1, 12) = %d, want 30 (leap)", got)
}
if got := monthLenJalali(2, 12); got != 29 {
t.Errorf("monthLenJalali(2, 12) = %d, want 29 (non-leap)", got)
}
if got := monthLenJalali(1403, 1); got != 31 {
t.Errorf("monthLenJalali(1403, 1) = %d, want 31", got)
}
if got := monthLenHijri(1, 1); got != 30 {
t.Errorf("monthLenHijri(1, 1) = %d, want 30 (odd month)", got)
}
if got := monthLenHijri(1, 2); got != 29 {
t.Errorf("monthLenHijri(1, 2) = %d, want 29 (even non-leap)", got)
}
if got := monthLenHijri(2, 12); got != 30 {
t.Errorf("monthLenHijri(2, 12) = %d, want 30 (leap year, month 12)", got)
}
}
func TestParseGregorianDateKey(t *testing.T) {
y, m, d := parseGregorianDateKey("2024-03-21")
if y != 2024 || m != 3 || d != 21 {
t.Errorf("parseGregorianDateKey got %d-%d-%d, want 2024-3-21", y, m, d)
}
}
func TestFormatDateForCalendar(t *testing.T) {
if got := formatDateForCalendar("2024-03-21", "gregorian"); got != "2024-03-21" {
t.Errorf("formatDateForCalendar gregorian: got %s", got)
}
if got := formatDateForCalendar("2024-03-21", "jalali"); got != "1403-01-02" {
t.Errorf("formatDateForCalendar jalali: got %s, want 1403-01-02", got)
}
if got := formatDateForCalendar("2024-03-21", "hijri"); got != "1445-09-11" {
t.Errorf("formatDateForCalendar hijri: got %s, want 1445-09-11", got)
}
}
func TestUserDateToGregorian(t *testing.T) {
if got := userDateToGregorian("1403-01-02", "jalali"); got != "2024-03-21" {
t.Errorf("userDateToGregorian jalali: got %s, want 2024-03-21", got)
}
if got := userDateToGregorian("1445-09-11", "hijri"); got != "2024-03-21" {
t.Errorf("userDateToGregorian hijri: got %s, want 2024-03-21", got)
}
}
func TestMonthGregorianRange(t *testing.T) {
start, end := monthGregorianRange("gregorian", 2024, 1)
if start != "2024-01-01" || end != "2024-01-31" {
t.Errorf("gregorian range: got %s - %s", start, end)
}
start, end = monthGregorianRange("jalali", 1403, 1)
if start != "2024-03-20" || end != "2024-04-19" {
t.Errorf("jalali range: got %s - %s", start, end)
}
start, end = monthGregorianRange("hijri", 1445, 9)
if start != "2024-03-11" || end != "2024-04-09" {
t.Errorf("hijri range: got %s - %s", start, end)
}
}
func TestBuildCalendarMonth(t *testing.T) {
cm := buildCalendarMonth("gregorian", 2024, 1)
if cm.title != "January 2024" {
t.Errorf("title: got %q", cm.title)
}
if len(cm.weekDays) != 7 {
t.Errorf("weekDays count: %d", len(cm.weekDays))
}
if len(cm.days) < 28 {
t.Errorf("too few days: %d", len(cm.days))
}
cm2 := buildCalendarMonth("jalali", 1403, 1)
if cm2.title != "Farvardin 1403" {
t.Errorf("jalali title: got %q", cm2.title)
}
if len(cm2.days) < 31 {
t.Errorf("too few jalali days: %d", len(cm2.days))
}
cm3 := buildCalendarMonth("hijri", 1445, 9)
if cm3.title != "Ramadan 1445" {
t.Errorf("hijri title: got %q", cm3.title)
}
}
func TestLoadLocation(t *testing.T) {
loc := loadLocation("Asia/Tehran")
if loc == nil {
t.Fatal("loadLocation returned nil")
}
if loc.String() != "Asia/Tehran" {
t.Errorf("unexpected location: %s", loc)
}
loc = loadLocation("Invalid/Timezone")
if loc.String() != "UTC" {
t.Errorf("expected UTC fallback, got %s", loc)
}
}
func TestFormatDuration(t *testing.T) {
if got := formatDuration(3600); got != "1h 0m" {
t.Errorf("formatDuration(3600) = %q, want %q", got, "1h 0m")
}
if got := formatDuration(3660); got != "1h 1m" {
t.Errorf("formatDuration(3660) = %q, want %q", got, "1h 1m")
}
if got := formatDuration(59); got != "0m" {
t.Errorf("formatDuration(59) = %q, want %q", got, "0m")
}
if got := formatDuration(1800); got != "30m" {
t.Errorf("formatDuration(1800) = %q, want %q", got, "30m")
}
if got := formatDuration(0); got != "0m" {
t.Errorf("formatDuration(0) = %q, want %q", got, "0m")
}
}

View File

@@ -1,700 +0,0 @@
package bot
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
bot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/db"
)
const (
rateLimitInterval = 1 * time.Second
DateLayout = "2006-01-02"
TimeLayout = "15:04"
secondsPerHour = 3600
secondsPerDay = 86400
defaultBreakThreshold = 300
maxBreakThresholdMins = 480
streakBonusCap = 500
xpCurveMultiplier = 50
salaryRoundFactor = 100
maxHourlyRate = 999999
)
type pendingNote struct {
eventID int64
createdAt time.Time
}
// Handler holds the bot instance, database store, user allowlist, and rate-limit state.
type Handler struct {
Bot *bot.Bot
DB *db.Store
AllowedUsers map[int64]bool
lastMsgTime map[int64]time.Time
pendingNotes map[int64]*pendingNote
mu sync.Mutex
}
// NewHandler creates a Handler and starts the rate-limit cleanup goroutine.
func NewHandler(bot *bot.Bot, store *db.Store, allowed map[int64]bool) *Handler {
h := &Handler{
Bot: bot,
DB: store,
AllowedUsers: allowed,
lastMsgTime: make(map[int64]time.Time),
pendingNotes: make(map[int64]*pendingNote),
}
go h.periodicCleanup()
return h
}
// periodicCleanup runs in the background, purging stale rate-limit entries and pending notes every 10 seconds.
func (h *Handler) periodicCleanup() {
for {
time.Sleep(10 * time.Second)
h.mu.Lock()
cutoff := time.Now().Add(-rateLimitInterval * 2)
for uid, t := range h.lastMsgTime {
if t.Before(cutoff) {
delete(h.lastMsgTime, uid)
}
}
noteCutoff := time.Now().Add(-5 * time.Minute)
for chatID, pn := range h.pendingNotes {
if pn.createdAt.Before(noteCutoff) {
delete(h.pendingNotes, chatID)
}
}
h.mu.Unlock()
}
}
type actionFunc func(int64) (string, error)
// isRateLimited returns true if the user has sent a message within the last rateLimitInterval.
func (h *Handler) isRateLimited(userID int64) bool {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < rateLimitInterval {
return true
}
h.lastMsgTime[userID] = now
return false
}
// handleActionMsg runs an action function and sends the result as a new message.
func (h *Handler) handleActionMsg(ctx context.Context, msg *models.Message, fn actionFunc) {
text, err := fn(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
kb := mainKeyboard()
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// handleActionCallback runs an action function and edits the callback message with the result.
func (h *Handler) handleActionCallback(ctx context.Context, chatID int64, msgID int, callbackID string, fn actionFunc) {
defer h.answerCb(ctx, callbackID)
text, err := fn(chatID)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
// HandleMessage routes incoming text messages to the appropriate handler.
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
}
if h.isRateLimited(user.ID) {
return
}
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
// Check for pending note — intercepts plain text only, commands always pass through.
h.mu.Lock()
pn, hasPN := h.pendingNotes[msg.Chat.ID]
if hasPN {
delete(h.pendingNotes, msg.Chat.ID)
}
h.mu.Unlock()
if hasPN && msg.Text[0] != '/' {
loc := loadLocation(user.Timezone)
event, err := h.getEventByID(user.ID, pn.eventID)
if err == nil {
if err := h.DB.SetEventNote(pn.eventID, SanitizeNote(msg.Text)); err != nil {
slog.Error("failed to save event note", "event_id", pn.eventID, "error", err)
}
date := time.Unix(event.OccurredAt, 0).In(loc).Format(DateLayout)
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
}
return
}
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":
h.handleHelp(ctx, msg)
case "/clockin":
h.handleActionMsg(ctx, msg, h.clockIn)
case "/clockout":
h.handleActionMsg(ctx, msg, h.clockOut)
case "/worktype":
h.handleWorkTypeMsg(ctx, msg)
case "/report":
h.handleActionMsg(ctx, msg, h.report)
case "/export":
h.handleExport(ctx, msg)
case "/dayoff":
h.handleActionMsg(ctx, msg, h.dayOff)
case "/history":
h.handleHistoryMsg(ctx, msg)
case "/edit":
h.handleEditMsg(ctx, msg)
case "/reporttoggle":
h.handleActionMsg(ctx, msg, h.toggleReport)
case "/setreporttime":
h.handleSetReportTime(ctx, msg)
case "/setaccent":
h.handleAccentMsg(ctx, msg)
case "/settimezone":
h.handleSetTimezone(ctx, msg)
case "/note":
h.handleNoteMsg(ctx, msg)
case "/summary":
h.handleSummaryMsg(ctx, msg)
case "/setbreak":
h.handleSetBreakMsg(ctx, msg)
case "/rpg":
h.handleRPGCmd(ctx, msg)
case "/achievements":
h.handleActionMsg(ctx, msg, h.achievementsList)
case "/salary":
h.handleActionMsg(ctx, msg, h.salaryEstimate)
case "/burnout":
h.handleActionMsg(ctx, msg, h.burnoutAssessment)
case "/league":
h.handleLeagueMsg(ctx, msg)
case "/setrate":
h.handleSetRateMsg(ctx, msg)
case "/setcurrency":
h.handleSetCurrencyMsg(ctx, msg)
case "/rpgtoggle":
h.handleRPGSettingsMsg(ctx, msg)
case "/leaguetoggle":
h.handleLeagueToggleCmd(ctx, msg)
case "/setusername":
h.handleSetUsername(ctx, msg)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown command")
}
}
// HandleCallback routes inline callback queries to the appropriate handler.
func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) {
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Message.Chat.ID] {
h.answerCbWithText(ctx, cb.ID, "Unauthorized")
return
}
user, err := h.getOrCreateUser(cb.Message.Message.Chat.ID)
if err != nil {
h.answerCb(ctx, cb.ID)
return
}
if h.isRateLimited(user.ID) {
h.answerCb(ctx, cb.ID)
return
}
slog.Info("callback", "chat_id", cb.Message.Message.Chat.ID, "data", cb.Data)
chatID := cb.Message.Message.Chat.ID
msgID := cb.Message.Message.ID
switch cb.Data {
case "clockin":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.clockIn)
case "clockout":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.clockOut)
case "worktype":
h.workTypeCallback(ctx, chatID, msgID, cb.ID)
case "report":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.report)
case "export":
h.exportCallback(ctx, chatID, msgID, cb.ID)
case "noop":
h.answerCb(ctx, cb.ID)
case "dayoff":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.dayOff)
case "settings":
h.settingsCallback(ctx, chatID, msgID, cb.ID)
case "caltype":
h.calTypeCallback(ctx, chatID, msgID, cb.ID)
case "accent":
h.accentCallback(ctx, chatID, msgID, cb.ID)
case "timezone":
h.timezoneCallback(ctx, chatID, msgID, cb.ID)
case "history":
h.historyCallback(ctx, chatID, msgID, cb.ID)
case "reporttime":
h.reportTimeCallback(ctx, chatID, msgID, cb.ID)
case "breakthreshold":
h.breakThresholdCallback(ctx, chatID, msgID, cb.ID)
case "back_menu":
h.backToMenu(ctx, chatID, msgID, cb.ID)
case "back_settings":
h.backToSettings(ctx, chatID, msgID, cb.ID)
case "rpg":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.rpgStatus)
case "achievements":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.achievementsList)
case "salary":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.salaryEstimate)
case "burnout":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.burnoutAssessment)
case "league":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "xp")
case "league_xp":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "xp")
case "league_level":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "level")
case "league_streak":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "streak")
case "league_hours":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "hours")
case "rpgtoggle":
h.rpgToggleCallback(ctx, chatID, msgID, cb.ID)
case "leaguetoggle":
h.leagueToggleCallback(ctx, chatID, msgID, cb.ID)
case "reporttoggle":
h.reportToggleCallback(ctx, chatID, msgID, cb.ID)
case "rpg_achievements":
h.rpgAchievementsCallback(ctx, chatID, msgID, cb.ID)
case "rpg_back":
h.rpgBackCallback(ctx, chatID, msgID, cb.ID)
case "username":
h.usernameCallback(ctx, chatID, msgID, cb.ID)
case "currency":
h.currencyCallback(ctx, chatID, msgID, cb.ID)
case "setrate":
h.rateCallback(ctx, chatID, msgID, cb.ID)
default:
h.handlePrefixedCallback(ctx, chatID, msgID, cb.ID, cb.Data)
}
}
func (h *Handler) handlePrefixedCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) {
if len(data) > 9 && data[:9] == "worktype_" {
h.selectWorkType(ctx, chatID, msgID, callbackID, data[9:])
return
}
if len(data) > 7 && data[:7] == "accent_" {
h.selectAccent(ctx, chatID, msgID, callbackID, data[7:])
return
}
if len(data) > 8 && data[:8] == "caltype_" {
h.selectCalendar(ctx, chatID, msgID, callbackID, data[8:])
return
}
if len(data) > 4 && data[:4] == "exp_" {
h.handleExportCallback(ctx, chatID, msgID, callbackID, data)
return
}
if len(data) > 15 && data[:15] == "breakthreshold_" {
h.selectBreakThreshold(ctx, chatID, msgID, callbackID, data[15:])
return
}
if len(data) > 9 && data[:9] == "currency_" {
h.selectCurrency(ctx, chatID, msgID, callbackID, data[9:])
return
}
switch data {
case "rpg_enable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "rpg_enable", true)
case "rpg_disable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "rpg_enable", false)
case "league_enable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "league_enable", true)
case "league_disable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "league_enable", false)
case "report_enable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "report_enable", true)
case "report_disable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "report_enable", false)
default:
h.handleHistoryCallback(ctx, chatID, msgID, callbackID, data)
}
}
// getOrCreateUser is a shortcut that delegates to the store.
func (h *Handler) getOrCreateUser(chatID int64) (*db.User, error) {
return h.DB.GetOrCreateUser(chatID)
}
// getUserToday fetches the user and today's day record, using the user's timezone.
func (h *Handler) getUserToday(chatID int64) (*db.User, *db.Day, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return nil, nil, err
}
loc, err := time.LoadLocation(user.Timezone)
if err != nil {
loc = time.UTC
}
today := time.Now().In(loc).Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, today)
if err != nil {
return nil, nil, err
}
return user, day, nil
}
// handleStart shows the main menu with the user's current status.
func (h *Handler) handleStart(ctx context.Context, msg *models.Message) {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
slog.Error("start: create user", "error", err)
h.sendText(ctx, msg.Chat.ID, "Error setting up your profile")
return
}
slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID)
kb := mainKeyboard()
h.sendWithKB(ctx, msg.Chat.ID, h.buildStatusText(msg.Chat.ID), kb)
}
// handleHelp shows the list of available commands.
func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
text := `Commands:
/start - Show main menu
/clockin - Clock in
/clockout - Clock out
/worktype - Select work type
/dayoff - Toggle day off
/report - Show today report
/export [YYYY-MM] - Export monthly report
/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
/reporttoggle - Enable/disable auto report
/setreporttime HH:MM - Set daily report time
/setaccent - Select accent color
/settimezone <name> - Set timezone (e.g. Asia/Tehran)
/setbreak <minutes> - Set break threshold in minutes
/setusername <name> - Set your display name
/rpg - Show RPG stats and XP
/achievements - View unlocked achievements
/salary - Show salary estimate
/burnout - View burnout assessment
/league - View WorkTime League rankings
/setrate <amount> - Set hourly rate (e.g. 25.50)
/setcurrency <symbol> <code> - Set currency (e.g. $ USD)
/rpgtoggle - Enable/disable RPG system
/leaguetoggle - Enable/disable league participation
Dates use your calendar type (Gregorian/Jalali/Hijri).
Buttons on the main menu also work.`
h.sendText(ctx, msg.Chat.ID, text)
}
// handleSetUsername handles /setusername <name>.
func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message) {
name := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setusername"))
if name == "" {
user, err := h.getOrCreateUser(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 username: %s\nUsage: /setusername <name>", user.Username))
return
}
name = SanitizeDisplayName(name)
if name == "" {
h.sendText(ctx, msg.Chat.ID, "Invalid username.")
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
user.Username = name
if err := h.DB.UpdateUser(user); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving username")
return
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Username set to: %s", name))
}
// handleNoteMsg processes the /note command: /note [YYYY-MM-DD] HH:MM <text>
func (h *Handler) handleNoteMsg(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)
parts := strings.Fields(msg.Text)
if len(parts) < 3 {
h.sendText(ctx, msg.Chat.ID, "Usage: /note [YYYY-MM-DD] HH:MM <text>")
return
}
var dateStr string
var timeStr string
var noteParts []string
// Check if first argument looks like a date
if len(parts[1]) == 10 && parts[1][4] == '-' && parts[1][7] == '-' {
dateStr = parts[1]
timeStr = parts[2]
noteParts = parts[3:]
} else {
dateStr = time.Now().In(loc).Format(DateLayout)
timeStr = parts[1]
noteParts = parts[2:]
}
events, err := h.DB.EventsForDayByDate(user.ID, dateStr)
if err != nil || len(events) == 0 {
h.sendText(ctx, msg.Chat.ID, "No events found for that day")
return
}
var targetEvent *db.Event
for i := range events {
t := time.Unix(events[i].OccurredAt, 0).In(loc).Format(TimeLayout)
if t == timeStr {
targetEvent = &events[i]
break
}
}
if targetEvent == nil {
h.sendText(ctx, msg.Chat.ID, "No event found at that time")
return
}
note := SanitizeNote(strings.Join(noteParts, " "))
if err := h.DB.SetEventNote(targetEvent.ID, note); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving note")
return
}
h.sendText(ctx, msg.Chat.ID, "Note saved")
}
// handleWorkTypeMsg shows the work type picker for today.
func (h *Handler) handleWorkTypeMsg(ctx context.Context, msg *models.Message) {
user, day, err := h.getUserToday(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading work types")
return
}
text, kb := h.buildWorkTypeKeyboard(user, day)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// workTypeCallback shows the work type picker (inline edit).
func (h *Handler) workTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, day, err := h.getUserToday(chatID)
if err != nil {
kb := backKeyboard()
h.editText(ctx, chatID, msgID, "Error loading work types", &kb)
return
}
text, kb := h.buildWorkTypeKeyboard(user, day)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildWorkTypeKeyboard returns the work type selection keyboard.
func (h *Handler) buildWorkTypeKeyboard(user *db.User, day *db.Day) (string, models.InlineKeyboardMarkup) {
wts, err := h.DB.GetWorkTypes()
if err != nil || len(wts) == 0 {
return "No work types available.", backKeyboard()
}
rows := [][]models.InlineKeyboardButton{}
for _, wt := range wts {
label := wt.Name
if wt.ID == day.CurrentWorkTypeID {
label = "> " + wt.Name
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: fmt.Sprintf("worktype_%d", wt.ID)},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
return "Select work type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// selectWorkType updates the day's work type from a callback.
func (h *Handler) selectWorkType(ctx context.Context, chatID int64, msgID int, callbackID, idStr string) {
defer h.answerCb(ctx, callbackID)
_, day, err := h.getUserToday(chatID)
if err != nil {
return
}
var wtID int64
if _, err := fmt.Sscanf(idStr, "%d", &wtID); err != nil {
return
}
wt, err := h.DB.GetWorkType(wtID)
if err != nil {
return
}
day.CurrentWorkTypeID = wtID
if err := h.DB.UpdateDay(day); err != nil {
return
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, fmt.Sprintf("Work type set to: %s", wt.Name), &kb)
}
// mainKeyboard returns the persistent inline menu.
func mainKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Clock In", CallbackData: "clockin"},
{Text: "Clock Out", CallbackData: "clockout"},
},
{
{Text: "Work Type", CallbackData: "worktype"},
{Text: "Day Off", CallbackData: "dayoff"},
},
{
{Text: "Report", CallbackData: "report"},
{Text: "Export", CallbackData: "export"},
{Text: "History", CallbackData: "history"},
},
{
{Text: "RPG", CallbackData: "rpg"},
{Text: "League", CallbackData: "league"},
{Text: "Burnout", CallbackData: "burnout"},
},
{
{Text: "Salary", CallbackData: "salary"},
{Text: "Settings", CallbackData: "settings"},
},
},
}
}
// backKeyboard returns a simple "Back to Menu" button.
func backKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
}
// formatDuration formats seconds as a human-readable duration (e.g. "7h 30m").
func formatDuration(seconds int64) string {
hours := seconds / secondsPerHour
mins := (seconds % secondsPerHour) / 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}
// sendText sends a plain text message to the given chat.
func (h *Handler) sendText(ctx context.Context, chatID int64, text string) {
if _, err := h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text}); err != nil {
slog.Warn("send text failed", "chat_id", chatID, "error", err)
}
}
// editText edits an existing message, logging errors.
func (h *Handler) editText(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
p := &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text}
if kb != nil {
p.ReplyMarkup = kb
}
if _, err := h.Bot.EditMessageText(ctx, p); err != nil {
slog.Warn("edit text failed", "chat_id", chatID, "msg_id", msgID, "error", err)
}
}
// sendWithKB sends a message with an inline keyboard, logging errors.
func (h *Handler) sendWithKB(ctx context.Context, chatID int64, text string, kb models.InlineKeyboardMarkup) {
if _, err := h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb}); err != nil {
slog.Warn("send with keyboard failed", "chat_id", chatID, "error", err)
}
}
// answerCb answers a callback query, logging errors.
func (h *Handler) answerCb(ctx context.Context, callbackID string) {
if _, err := h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil {
slog.Debug("answer callback failed", "error", err)
}
}
// answerCbWithText answers a callback query with a toast message, logging errors.
func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string) {
if _, err := h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID, Text: text}); err != nil {
slog.Debug("answer callback with text failed", "error", err)
}
}
// navMonth returns the (prevY, prevM, nextY, nextM) for month navigation.
func navMonth(year, month int) (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++
}
return
}
// sendOrEdit sends a new message or edits an existing one depending on msgID.
func (h *Handler) sendOrEdit(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
if msgID == 0 {
if kb != nil {
h.sendWithKB(ctx, chatID, text, *kb)
} else {
h.sendText(ctx, chatID, text)
}
} else {
h.editText(ctx, chatID, msgID, text, kb)
}
}

View File

@@ -1,187 +0,0 @@
package bot
import (
"context"
"fmt"
"log/slog"
"time"
bot "github.com/go-telegram/bot"
"worktimeBot/internal/db"
)
// buildStatusText returns the main menu status text for the current user.
func (h *Handler) buildStatusText(chatID int64) string {
user, day, err := h.getUserToday(chatID)
if err != nil {
return "Welcome. Choose an action:"
}
text := fmt.Sprintf("Today: %s", formatDateForCalendar(day.Date, user.Calendar))
if day.IsDayOff {
text += "\nDay Off"
}
wt, _ := h.DB.GetWorkType(day.CurrentWorkTypeID)
if wt != nil {
text += fmt.Sprintf("\nWork type: %s", wt.Name)
}
last, err := h.DB.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
loc := loadLocation(user.Timezone)
t := time.Unix(last.OccurredAt, 0).In(loc).Format(TimeLayout)
text += fmt.Sprintf("\nStatus: working since %s", t)
} else {
text += "\nStatus: not working"
}
events, err := h.DB.EventsForDayByDayID(day.ID)
if err == nil && len(events) > 0 {
loc := 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))
}
// Salary info if configured
st, _ := h.DB.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
if events, err := h.DB.EventsForDayByDayID(day.ID); err == nil && len(events) > 0 {
loc := 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)
salLine := h.buildSalaryStatus(user.ID, totals.TotalSeconds)
if salLine != "" {
text += salLine
}
}
}
// RPG info if enabled
if st.RPGEnabled {
stats, _ := h.DB.GetOrCreateRPGStats(user.ID)
if stats != nil {
text += fmt.Sprintf("\nLevel: %d | XP: %d", stats.Level, stats.XP)
rankText := h.buildLeagueRankText(user.ID)
if rankText != "" {
text += fmt.Sprintf("\n %s", rankText)
}
}
}
text += "\n\nChoose an action:"
return text
}
// backToMenu returns to the main menu from any sub-menu.
func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
kb := mainKeyboard()
h.editText(ctx, chatID, msgID, h.buildStatusText(chatID), &kb)
}
// buildDailyReport builds a formatted daily report string for a given user/day.
func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event) string {
loc, err := time.LoadLocation(user.Timezone)
if err != nil {
loc = time.UTC
}
if len(events) == 0 {
if day.IsDayOff {
return fmt.Sprintf("%s - Day Off", formatDateForCalendar(day.Date, user.Calendar))
}
return fmt.Sprintf("%s - No events recorded.", formatDateForCalendar(day.Date, user.Calendar))
}
y, m, d := parseGregorianDateKey(day.Date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
now := time.Now().In(loc)
todayStr := now.Format(DateLayout)
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
if day.Date == todayStr {
dayEnd = now.Unix()
}
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
lines := fmt.Sprintf("Report for %s", formatDateForCalendar(day.Date, user.Calendar))
if day.IsDayOff {
lines += "\nDay Off: Yes"
}
lines += "\n\n"
for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format(TimeLayout)
label := "IN"
if e.EventType == "out" {
label = "OUT"
}
suffix := ""
if e.WorkTypeID != nil {
if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil {
suffix = " [" + wtObj.Name + "]"
}
}
if e.Note != "" {
suffix += " (" + e.Note + ")"
}
lines += fmt.Sprintf("%s %s%s\n", label, t, suffix)
}
lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n",
formatDuration(totals.TotalSeconds),
formatDuration(totals.BreakSeconds))
// Salary info if configured
st, _ := h.DB.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
est := computeDailySalary(totals.TotalSeconds, st.HourlyRate)
lines += fmt.Sprintf(" Salary: %s\n", SalaryEstimate{GrossEarning: est}.formatSalary(st.Currency))
}
return lines
}
// SendDailyReport sends the automated daily report to the user.
func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int64) {
user, err := h.DB.GetUserByChatID(chatID)
if err != nil {
slog.Error("daily report: user not found", "user_id", userID)
return
}
loc, err := time.LoadLocation(user.Timezone)
if err != nil {
loc = time.UTC
}
now := time.Now().In(loc)
today := now.Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, today)
if err != nil {
slog.Error("daily report: get day", "error", err)
return
}
events, err := h.DB.EventsForDayByDayID(day.ID)
if err != nil {
slog.Error("daily report: get events", "error", err)
return
}
text := h.buildDailyReport(user, day, events)
_, err = h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text})
if err != nil {
slog.Error("daily report: send message failed", "chat_id", chatID, "error", err)
return
}
if err := h.DB.MarkReportSent(user.ID, today); err != nil {
slog.Error("daily report: mark sent failed", "chat_id", chatID, "error", err)
}
}

View File

@@ -1,450 +0,0 @@
package bot
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/db"
)
// xpForLevel returns the total XP required to reach level n.
// Quadratic curve: totalXP(n) = n * (n + 1) * xpCurveMultiplier
// Level 1 = 100 XP, Level 10 = 5500 XP, Level 50 = 127500 XP
func xpForLevel(n int) int64 {
return int64(n) * int64(n+1) * xpCurveMultiplier
}
// levelForXP returns the level for a given total XP amount.
func levelForXP(xp int64) int {
l := 1
for xpForLevel(l) <= xp {
l++
}
return l - 1
}
// xpForWorkSeconds calculates XP earned for a given amount of work.
// Base: 1 XP per second.
func xpForWorkSeconds(sec int64) int64 {
return sec
}
// streakBonus returns bonus XP for maintaining a streak.
// +xpCurveMultiplier XP per day of current streak, capped at streakBonusCap.
func streakBonus(streak int) int64 {
b := int64(streak) * xpCurveMultiplier
if b > streakBonusCap {
b = streakBonusCap
}
return b
}
// computeStreakFromDates computes current and longest streak from a sorted list of date strings.
// today is the reference date for the current streak.
func computeStreakFromDates(dates []string, today string) (current, longest int) {
if len(dates) == 0 {
return 0, 0
}
dateSet := make(map[string]struct{}, len(dates))
for _, d := range dates {
dateSet[d] = struct{}{}
}
// Current streak: count consecutive days backwards from today
if _, ok := dateSet[today]; ok {
current = 1
for i := 1; ; i++ {
t, _ := time.Parse(DateLayout, today)
prev := t.AddDate(0, 0, -i).Format(DateLayout)
if _, ok := dateSet[prev]; ok {
current++
} else {
break
}
}
}
// Longest streak: find longest consecutive run in sorted dates
longest = 0
run := 1
for i := 1; i < len(dates); i++ {
prev, _ := time.Parse(DateLayout, dates[i-1])
curr, _ := time.Parse(DateLayout, dates[i])
if curr.Sub(prev).Hours() == 24 {
run++
} else {
if run > longest {
longest = run
}
run = 1
}
}
if run > longest {
longest = run
}
return current, longest
}
// computeAndAwardXP calculates XP for a work session and persists it.
// stats must already have the streak updated before calling.
// Returns the new total XP, whether the user leveled up, and the new level.
func (h *Handler) computeAndAwardXP(stats *db.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) {
baseXP := xpForWorkSeconds(workSeconds)
bonus := streakBonus(streak)
totalXP := baseXP + bonus
if totalXP < 0 {
totalXP = 0
}
stats.XP += totalXP
newLevel = levelForXP(stats.XP)
leveledUp = newLevel > stats.Level
stats.Level = newLevel
stats.TotalWorkSeconds += workSeconds
if err := h.DB.UpdateRPGStats(stats); err != nil {
return 0, false, 0, err
}
if err := h.DB.UpsertDailyXP(userID, date, totalXP, workSeconds); err != nil {
slog.Warn("failed to upsert daily xp", "user_id", userID, "error", err)
}
return stats.XP, leveledUp, newLevel, nil
}
// updateStreak advances or resets the user's streak based on today's activity.
// Only called from clockOut — incremental update, not a full scan.
func (h *Handler) updateStreak(stats *db.RPGStats, userID int64, date string) {
t, err := time.Parse(DateLayout, date)
if err != nil {
return
}
yesterday := t.AddDate(0, 0, -1).Format(DateLayout)
prevEvents, err := h.DB.EventsForDayByDate(userID, yesterday)
hadWorkYesterday := err == nil && len(prevEvents) > 0
if hadWorkYesterday {
stats.CurrentStreak++
} else {
stats.CurrentStreak = 1
}
if stats.CurrentStreak > stats.LongestStreak {
stats.LongestStreak = stats.CurrentStreak
}
}
// recalcDayWorkSeconds recomputes work_seconds for a single day from events and updates daily_xp.
func (h *Handler) recalcDayWorkSeconds(userID int64, date string, loc *time.Location) {
events, err := h.DB.EventsForDayByDate(userID, date)
if err != nil {
return
}
day, err := h.DB.GetDay(userID, date)
if err != nil {
return
}
y, m, d := parseGregorianDateKey(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)
if err := h.DB.SetDailyWorkSeconds(userID, date, totals.TotalSeconds); err != nil {
slog.Warn("failed to set daily work seconds", "user_id", userID, "date", date, "error", err)
}
}
// recalcAggregates recomputes total_work_seconds and streak from scratch after event edits.
func (h *Handler) recalcAggregates(userID int64, loc *time.Location) {
total, err := h.DB.SumDailyWorkSeconds(userID)
if err != nil {
slog.Warn("failed to sum daily work seconds", "user_id", userID, "error", err)
return
}
stats, err := h.DB.GetOrCreateRPGStats(userID)
if err != nil {
slog.Warn("failed to get rpg stats for recalc", "user_id", userID, "error", err)
return
}
stats.TotalWorkSeconds = total
// Recompute streak from scratch
dates, err := h.DB.GetDatesWithEvents(userID)
today := time.Now().In(loc).Format(DateLayout)
if err == nil {
stats.CurrentStreak, stats.LongestStreak = computeStreakFromDates(dates, today)
} else {
stats.CurrentStreak = 0
stats.LongestStreak = 0
}
if err := h.DB.UpdateRPGStats(stats); err != nil {
slog.Warn("failed to update rpg stats after recalc", "user_id", userID, "error", err)
}
}
// checkAchievements evaluates and unlocks any newly earned achievements.
func (h *Handler) checkAchievements(userID int64, stats *db.RPGStats, workSeconds int64, date string, isWeekend bool, totalHours float64) []string {
unlocker := newAchievementUnlocker(h, userID)
h.checkEarlyAchievements(unlocker, workSeconds)
h.checkEventTimeAchievements(unlocker, userID, date)
h.checkStreakAchievements(unlocker, stats)
h.checkHourAchievements(unlocker, stats, totalHours)
h.checkSessionAchievements(unlocker, workSeconds, isWeekend)
h.checkLevelAchievements(unlocker, stats)
return unlocker.unlocked
}
type achievementUnlocker struct {
h *Handler
userID int64
unlocked []string
}
func newAchievementUnlocker(h *Handler, userID int64) *achievementUnlocker {
return &achievementUnlocker{h: h, userID: userID}
}
func (u *achievementUnlocker) unlock(code string) {
a, err := u.h.DB.GetAchievementByCode(code)
if err != nil {
return
}
ok, err := u.h.DB.UnlockAchievement(u.userID, a.ID)
if err != nil {
slog.Error("unlock achievement", "code", code, "error", err)
return
}
if ok {
u.unlocked = append(u.unlocked, a.Name)
}
}
func (h *Handler) checkEarlyAchievements(u *achievementUnlocker, workSeconds int64) {
has, _ := h.DB.HasAchievement(u.userID, "first_clockin")
if !has && workSeconds > 0 {
u.unlock("first_clockin")
}
}
func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) {
t, _ := time.Parse(DateLayout, date)
dayStart := t.Unix()
dayEnd := t.Add(24 * time.Hour).Unix()
events, err := h.DB.EventsForDayByDate(userID, date)
if err != nil {
return
}
for _, e := range events {
if e.OccurredAt < dayStart || e.OccurredAt > dayEnd {
continue
}
et := time.Unix(e.OccurredAt, 0)
hh, mm, _ := et.Clock()
minutes := hh*60 + mm
if e.EventType == "in" && minutes < 7*60 {
u.unlock("early_bird")
}
if e.EventType == "out" && minutes >= 22*60 {
u.unlock("night_owl")
}
}
}
func (h *Handler) checkStreakAchievements(u *achievementUnlocker, stats *db.RPGStats) {
if stats.CurrentStreak >= 5 {
u.unlock("iron_streak_5")
}
if stats.CurrentStreak >= 10 {
u.unlock("iron_streak_10")
}
if stats.CurrentStreak >= 30 {
u.unlock("iron_streak_30")
}
}
func (h *Handler) checkHourAchievements(u *achievementUnlocker, stats *db.RPGStats, hoursWorked float64) {
if hoursWorked >= 100.0 {
u.unlock("hundred_hours")
}
if hoursWorked >= 500.0 {
u.unlock("five_hundred_hours")
}
}
func (h *Handler) checkSessionAchievements(u *achievementUnlocker, workSeconds int64, isWeekend bool) {
if workSeconds > 10*secondsPerHour {
u.unlock("overtime_hero")
}
if isWeekend && workSeconds > 0 {
u.unlock("weekend_warrior")
}
}
func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *db.RPGStats) {
if stats.Level >= 5 {
u.unlock("level_5")
}
if stats.Level >= 10 {
u.unlock("level_10")
}
if stats.Level >= 25 {
u.unlock("level_25")
}
if stats.Level >= 50 {
u.unlock("level_50")
}
}
// buildRPGStatus returns a formatted string showing the user's RPG stats.
func (h *Handler) buildRPGStatus(userID int64, loc *time.Location) string {
stats, err := h.DB.GetOrCreateRPGStats(userID)
if err != nil {
return "RPG stats unavailable"
}
nextLevelXP := xpForLevel(stats.Level + 1)
currentLevelXP := xpForLevel(stats.Level)
xpIntoLevel := stats.XP - currentLevelXP
xpNeeded := nextLevelXP - currentLevelXP
var b strings.Builder
b.WriteString(fmt.Sprintf("Level: %d\n", stats.Level))
b.WriteString(fmt.Sprintf("XP: %d / %d (%d%%)\n", stats.XP, nextLevelXP, xpIntoLevel*100/xpNeeded))
b.WriteString(fmt.Sprintf("Streak: %d days (best: %d)\n", stats.CurrentStreak, stats.LongestStreak))
hoursTotal := float64(stats.TotalWorkSeconds) / secondsPerHour
b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal))
// Show achievements count
achs, _ := h.DB.GetUserAchievements(userID)
if len(achs) > 0 {
b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs)))
}
return b.String()
}
// buildAchievementsList returns the user's unlocked achievements.
func (h *Handler) buildAchievementsList(userID int64) string {
achs, err := h.DB.GetUserAchievements(userID)
if err != nil {
return "Achievements unavailable"
}
if len(achs) == 0 {
return "No achievements unlocked yet.\nWork consistently to earn them!"
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Achievements (%d):\n\n", len(achs)))
for _, a := range achs {
t := time.Unix(a.UnlockedAt, 0)
b.WriteString(fmt.Sprintf(" %s\n %s - %s\n", a.Name, a.Description, t.Format(DateLayout)))
}
return b.String()
}
// rpgStatus returns the user's RPG stats.
func (h *Handler) rpgStatus(chatID int64) (string, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return "", err
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return "", err
}
if !st.RPGEnabled {
return "RPG system is disabled. Enable it in Settings or use /rpgtoggle.", nil
}
loc := loadLocation(user.Timezone)
return h.buildRPGStatus(user.ID, loc), nil
}
// handleRPGCmd handles /rpg — shows stats with an Achievements button.
func (h *Handler) handleRPGCmd(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
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.RPGEnabled {
h.sendText(ctx, msg.Chat.ID, "RPG system is disabled. Enable it in Settings or use /rpgtoggle.")
return
}
loc := loadLocation(user.Timezone)
text := h.buildRPGStatus(user.ID, loc)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// rpgAchievementsCallback shows achievements from the RPG view.
func (h *Handler) rpgAchievementsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text := h.buildAchievementsList(user.ID)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to RPG", CallbackData: "rpg_back"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// rpgBackCallback returns from achievements to the RPG stats view.
func (h *Handler) rpgBackCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
loc := loadLocation(user.Timezone)
text := h.buildRPGStatus(user.ID, loc)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// achievementsList returns the user's unlocked achievements.
func (h *Handler) achievementsList(chatID int64) (string, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return "", err
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return "", err
}
if !st.RPGEnabled {
return "RPG system is disabled. Enable it in Settings or use /rpgtoggle.", nil
}
return h.buildAchievementsList(user.ID), nil
}

View File

@@ -1,304 +0,0 @@
package bot
import (
"context"
"fmt"
"math"
"strings"
"time"
"unicode"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/db"
)
// SalaryEstimate holds a salary calculation result.
type SalaryEstimate struct {
Currency string
HourlyRate float64
WorkSeconds int64
WorkHours float64
GrossEarning float64
WorkDays int
}
// computeDailySalary calculates salary for a single day's work.
func computeDailySalary(workSeconds int64, hourlyRate float64) float64 {
hours := float64(workSeconds) / secondsPerHour
return math.Round(hours*hourlyRate*salaryRoundFactor) / salaryRoundFactor
}
// computeMonthlySalary calculates salary for a range of days.
func computeMonthlySalary(days []db.Day, store *db.Store, userID int64, hourlyRate float64, loc *time.Location) SalaryEstimate {
var totalWork int64
workDayCount := 0
for _, day := range days {
if day.IsDayOff {
continue
}
events, err := store.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)
if totals.TotalSeconds > 0 {
totalWork += totals.TotalSeconds
workDayCount++
}
}
hours := float64(totalWork) / secondsPerHour
gross := math.Round(hours*hourlyRate*salaryRoundFactor) / salaryRoundFactor
return SalaryEstimate{
WorkSeconds: totalWork,
WorkHours: math.Round(hours*salaryRoundFactor) / salaryRoundFactor,
GrossEarning: gross,
WorkDays: workDayCount,
}
}
// currencySymbol extracts the first token as symbol, defaulting to "$".
func currencySymbol(currency string) string {
if parts := strings.Fields(currency); len(parts) >= 1 && parts[0] != "" {
return parts[0]
}
return "$"
}
// formatSalary formats a salary estimate into a readable string.
func (e SalaryEstimate) formatSalary(currency string) string {
sym := currencySymbol(currency)
code := "USD"
if parts := strings.Fields(currency); len(parts) >= 2 {
code = parts[1]
}
return fmt.Sprintf("%s%.2f %s", sym, e.GrossEarning, code)
}
// formatSalaryShort returns just the symbol + amount.
func (e SalaryEstimate) formatSalaryShort(currency string) string {
return fmt.Sprintf("%s%.2f", currencySymbol(currency), e.GrossEarning)
}
// buildSalaryStatus returns a formatted daily salary line.
func (h *Handler) buildSalaryStatus(userID int64, workSeconds int64) string {
st, err := h.DB.GetOrCreateSettings(userID)
if err != nil || st.HourlyRate <= 0 {
return ""
}
est := computeDailySalary(workSeconds, st.HourlyRate)
s := SalaryEstimate{WorkSeconds: workSeconds, GrossEarning: est, HourlyRate: st.HourlyRate}
return fmt.Sprintf("\nSalary: %s (%.2f/hr)", s.formatSalaryShort(st.Currency), st.HourlyRate)
}
// salaryEstimate returns the salary estimate for the current month.
func (h *Handler) salaryEstimate(chatID int64) (string, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return "", err
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return "", err
}
if st.HourlyRate <= 0 {
return "No hourly rate configured.\nUse /setrate <amount> to set your rate.", nil
}
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
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)
days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr)
if err != nil {
return "", err
}
est := computeMonthlySalary(days, h.DB, user.ID, st.HourlyRate, loc)
est.Currency = st.Currency
est.HourlyRate = st.HourlyRate
title := formatMonthTitle(user.Calendar, calY, calM)
return fmt.Sprintf("Salary estimate for %s\n%s\nRate: %s%.2f/hr\nDays worked: %d\nHours: %.1f",
title, est.formatSalary(st.Currency), currencySymbol(st.Currency), st.HourlyRate,
est.WorkDays, est.WorkHours), nil
}
// handleSetRateMsg handles /setrate <amount>.
func (h *Handler) handleSetRateMsg(ctx context.Context, msg *models.Message) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setrate"))
if args == "" {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current hourly rate: %s%.2f\nUsage: /setrate <amount> (e.g. /setrate 25.50)", currencySymbol(st.Currency), st.HourlyRate))
} else {
h.sendText(ctx, msg.Chat.ID, "No rate configured.\nUsage: /setrate <amount> (e.g. /setrate 25.50)")
}
return
}
var rate float64
if _, err := fmt.Sscanf(args, "%f", &rate); err != nil || rate < 0 || rate > maxHourlyRate {
h.sendText(ctx, msg.Chat.ID, "Invalid rate. Use a positive number (e.g. /setrate 25.50)")
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving rate")
return
}
st.HourlyRate = float64(int(rate*salaryRoundFactor)) / 100.0
if err := h.DB.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving rate")
return
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", currencySymbol(st.Currency), st.HourlyRate))
}
// parseCurrencyInput validates and normalizes currency input (symbol + code).
func parseCurrencyInput(s string) (string, string) {
parts := strings.Fields(s)
if len(parts) < 2 {
return "", "Please provide both symbol and code.\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial"
}
for _, p := range parts[:2] {
if len(p) > 10 || strings.ContainsFunc(p, unicode.IsControl) {
return "", "Invalid currency. Use short printable strings like $ USD, T Toman, IRR Rial."
}
}
return parts[0] + " " + parts[1], ""
}
// handleSetCurrencyMsg handles /setcurrency <symbol> <code>.
func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency"))
if args == "" {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current currency: %s\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial", st.Currency))
return
}
currency, errMsg := parseCurrencyInput(args)
if errMsg != "" {
h.sendText(ctx, msg.Chat.ID, errMsg)
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving currency")
return
}
st.Currency = currency
if err := h.DB.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving currency")
return
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Currency set to: %s", currency))
}
// currencyCallback shows the currency picker (inline).
func (h *Handler) currencyCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
text, kb := buildCurrencyKeyboard(st.Currency)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildCurrencyKeyboard returns the currency preset picker keyboard.
func buildCurrencyKeyboard(current string) (string, models.InlineKeyboardMarkup) {
presets := []string{"$ USD", "T Toman", "IRR Rial", "€ EUR", "£ GBP"}
rows := [][]models.InlineKeyboardButton{}
for _, p := range presets {
label := p
if p == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "currency_" + p},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select currency:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// selectCurrency saves the chosen currency preset.
func (h *Handler) selectCurrency(ctx context.Context, chatID int64, msgID int, callbackID, currency string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.Currency = currency
if err := h.DB.UpdateSettings(st); err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// rateCallback shows rate info (inline).
func (h *Handler) rateCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
var text string
if st.HourlyRate > 0 {
text = fmt.Sprintf("Hourly rate: %s%.2f\n\nUse /setrate <amount> to change it.\nExample: /setrate 25.50", currencySymbol(st.Currency), st.HourlyRate)
} else {
text = "No hourly rate configured.\n\nUse /setrate <amount> to set your rate.\nExample: /setrate 25.50"
}
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}

View File

@@ -1,684 +0,0 @@
package bot
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/db"
)
// handleSetReportTime parses a HH:MM argument and updates the user's report time.
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message) {
args := ""
if len(msg.Text) > 16 {
args = strings.TrimSpace(msg.Text[15:])
}
if args == "" {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", st.ReportTime))
return
}
if len(args) != 5 || args[2] != ':' {
h.sendText(ctx, msg.Chat.ID, "Invalid format. Usage: /setreporttime HH:MM (24-hour)")
return
}
hours, err1 := strconv.Atoi(args[:2])
mins, err2 := strconv.Atoi(args[3:])
if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 {
h.sendText(ctx, msg.Chat.ID, "Invalid time. Usage: /setreporttime HH:MM (24-hour)")
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error updating report time")
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error updating report time")
return
}
st.ReportTime = args
if err := h.DB.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error updating report time")
return
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Report time set to %s", args))
}
// handleAccentMsg shows the accent color picker.
func (h *Handler) handleAccentMsg(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
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
text, kb := h.buildAccentKeyboard(st)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// handleSetTimezone validates and updates the user's IANA timezone.
func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message) {
args := ""
if len(msg.Text) > 13 { // "/settimezone " is 12 chars
args = strings.TrimSpace(msg.Text[13:])
}
if args == "" {
user, err := h.getOrCreateUser(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 timezone: %s\nUsage: /settimezone <IANA timezone>\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone))
return
}
loc, err := time.LoadLocation(args)
if err != nil {
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran, Europe/London", args))
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
user.Timezone = args
if err := h.DB.UpdateUser(user); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving timezone")
return
}
now := time.Now().In(loc)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now.Format(TimeLayout)))
}
// getTodayBreakThreshold returns today's break threshold in seconds for the given chat.
func (h *Handler) getTodayBreakThreshold(chatID int64) int64 {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return defaultBreakThreshold
}
loc, err := time.LoadLocation(user.Timezone)
if err != nil {
loc = time.UTC
}
date := time.Now().In(loc).Format(DateLayout)
day, err := h.DB.GetDay(user.ID, date)
if err != nil {
return defaultBreakThreshold
}
return day.MinBreakThreshold
}
// 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
}
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
date := now.Format(DateLayout)
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) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildSettingsKeyboard returns the settings menu text and inline keyboard.
func (h *Handler) buildSettingsKeyboard(user *db.User, st *db.UserSettings, breakThreshold int64) (string, models.InlineKeyboardMarkup) {
reportStatus := "off"
if st.ReportEnabled {
reportStatus = "on"
}
rpgStatus := "off"
if st.RPGEnabled {
rpgStatus = "on"
}
leagueStatus := "off"
if !st.RPGEnabled {
leagueStatus = "off"
} else if st.LeagueOptIn {
leagueStatus = "on"
}
rows := [][]models.InlineKeyboardButton{
{
{Text: fmt.Sprintf("Username: %s", user.Username), CallbackData: "username"},
},
{
{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"},
{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"},
},
{
{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"},
{Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"},
},
{
{Text: fmt.Sprintf("Break: %dm", breakThreshold/60), CallbackData: "breakthreshold"},
{Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"},
},
{
{Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"},
{Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"},
},
{
{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"},
{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"},
},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
}
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// usernameCallback shows the current username and how to change it.
func (h *Handler) usernameCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text := fmt.Sprintf("Username: %s\n\nUse /setusername <name> to change it.", user.Username)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// accentCallback shows the accent color picker (inline edit).
func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildAccentKeyboard(st)
h.editText(ctx, chatID, msgID, text, &kb)
}
// timezoneCallback shows the current timezone and instructions to change it.
func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text := fmt.Sprintf("Current timezone: %s\n\nUse /settimezone <name> to change it.\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildAccentKeyboard returns the accent color picker keyboard.
func (h *Handler) buildAccentKeyboard(st *db.UserSettings) (string, models.InlineKeyboardMarkup) {
type accentOption struct {
name string
color string
}
accents := []accentOption{
{"ocean", "Blue"},
{"beach", "Warm"},
{"rose", "Pink"},
{"catppuccin", "Purple"},
}
rows := [][]models.InlineKeyboardButton{}
for _, a := range accents {
label := fmt.Sprintf("%s (%s)", a.name, a.color)
if a.name == st.ExportAccent {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "accent_" + a.name},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select accent color:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// selectAccent sets the user's export accent color after validation.
func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true}
if !valid[name] {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.ExportAccent = name
if err := h.DB.UpdateSettings(st); err != nil {
return
}
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Accent set to: %s", name), &kb)
}
// reportTimeCallback shows the current report time setting.
func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", st.ReportTime)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// breakThresholdCallback shows the break threshold picker.
func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildBreakThresholdKeyboard(bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildBreakThresholdKeyboard returns the break threshold picker.
func (h *Handler) buildBreakThresholdKeyboard(current int64) (string, models.InlineKeyboardMarkup) {
values := []int{0, 5, 10, 15, 30, 60}
labels := map[int]string{
0: "0m",
5: "5m",
10: "10m",
15: "15m",
30: "30m",
60: "1h",
}
rows := [][]models.InlineKeyboardButton{}
for _, v := range values {
label := labels[v]
if int(current/60) == v {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: fmt.Sprintf("breakthreshold_%d", v)},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select break threshold:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// selectBreakThreshold saves the chosen break threshold and returns to settings.
func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, callbackID, valStr string) {
defer h.answerCb(ctx, callbackID)
mins, err := strconv.Atoi(valStr)
if err != nil || mins < 0 || mins > maxBreakThresholdMins {
return
}
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
now := time.Now()
date := now.Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, date)
if err != nil {
return
}
day.MinBreakThreshold = int64(mins) * 60
if err := h.DB.UpdateDay(day); err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// backToSettings returns to the main settings menu.
func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// calTypeCallback shows the calendar type selector.
func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text, kb := h.buildCalendarKeyboard(user)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildCalendarKeyboard returns the calendar type selector keyboard.
func (h *Handler) buildCalendarKeyboard(user *db.User) (string, models.InlineKeyboardMarkup) {
cals := []string{"gregorian", "jalali", "hijri"}
calLabels := map[string]string{
"gregorian": "Gregorian",
"jalali": "Jalali",
"hijri": "Hijri",
}
rows := [][]models.InlineKeyboardButton{}
for _, c := range cals {
label := calLabels[c]
if c == user.Calendar {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "caltype_" + c},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select calendar type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// selectCalendar sets the user's calendar after validation.
func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
defer h.answerCb(ctx, 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
}
bt := h.getTodayBreakThreshold(chatID)
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// handleRPGSettingsMsg shows the RPG enable/disable picker (command).
func (h *Handler) handleRPGSettingsMsg(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
}
text, kb := h.buildRPGToggleKeyboard(user.ID)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// handleLeagueToggleCmd handles /leaguetoggle — checks RPG is enabled first.
func (h *Handler) handleLeagueToggleCmd(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
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.RPGEnabled {
h.sendText(ctx, msg.Chat.ID, "League requires the RPG system. Enable RPG first with /rpgtoggle.")
return
}
text, kb := h.buildLeagueToggleKeyboard(user.ID)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// rpgToggleCallback shows the RPG enable/disable picker (inline).
func (h *Handler) rpgToggleCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text, kb := h.buildRPGToggleKeyboard(user.ID)
h.editText(ctx, chatID, msgID, text, &kb)
}
// leagueToggleCallback shows the League enable/disable picker (inline).
func (h *Handler) leagueToggleCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if !st.RPGEnabled {
h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", nil)
return
}
text, kb := h.buildLeagueToggleKeyboard(user.ID)
h.editText(ctx, chatID, msgID, text, &kb)
}
// reportToggleCallback shows the Report enable/disable picker (inline) — replaces simple toggle.
func (h *Handler) reportToggleCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text, kb := h.buildReportToggleKeyboard(user.ID)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildRPGToggleKeyboard returns the RPG enable/disable picker.
func (h *Handler) buildRPGToggleKeyboard(userID int64) (string, models.InlineKeyboardMarkup) {
st, err := h.DB.GetOrCreateSettings(userID)
current := false
if err == nil {
current = st.RPGEnabled
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range []struct {
label string
data string
value bool
}{
{"Enabled", "rpg_enable", true},
{"Disabled", "rpg_disable", false},
} {
label := opt.label
if opt.value == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "RPG System:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// buildLeagueToggleKeyboard returns the League enable/disable picker.
func (h *Handler) buildLeagueToggleKeyboard(userID int64) (string, models.InlineKeyboardMarkup) {
st, err := h.DB.GetOrCreateSettings(userID)
current := false
if err == nil {
current = st.LeagueOptIn
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range []struct {
label string
data string
value bool
}{
{"Enabled", "league_enable", true},
{"Disabled", "league_disable", false},
} {
label := opt.label
if opt.value == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "WorkTime League:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// buildReportToggleKeyboard returns the Report enable/disable picker.
func (h *Handler) buildReportToggleKeyboard(userID int64) (string, models.InlineKeyboardMarkup) {
st, err := h.DB.GetOrCreateSettings(userID)
current := true
if err == nil {
current = st.ReportEnabled
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range []struct {
label string
data string
value bool
}{
{"Enabled", "report_enable", true},
{"Disabled", "report_disable", false},
} {
label := opt.label
if opt.value == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Daily Report:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// handleSelectCallback handles binary select callbacks (report_enable, rpg_enable, etc.).
func (h *Handler) handleSelectCallback(ctx context.Context, chatID int64, msgID int, callbackID string, setting string, value bool) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
switch setting {
case "report_enable":
st.ReportEnabled = value
case "rpg_enable":
st.RPGEnabled = value
if value {
h.DB.GetOrCreateRPGStats(user.ID)
} else {
st.LeagueOptIn = false
}
case "league_enable":
if value && !st.RPGEnabled {
h.answerCbWithText(ctx, callbackID, "Enable RPG first")
return
}
st.LeagueOptIn = value
}
if err := h.DB.UpdateSettings(st); err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}

View File

@@ -1,29 +0,0 @@
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)
}
})
}
}

View File

@@ -1,90 +0,0 @@
package bot
import (
"sort"
"worktimeBot/internal/db"
)
// State represents the current tracking state during daily totals computation.
type State int
const (
// StateIdle indicates no active work session.
StateIdle State = iota
// StateWorking indicates an active work session.
StateWorking
// StateOnBreak indicates an active break session.
StateOnBreak
)
// DailyTotals holds the computed total work and break seconds for a single day.
type DailyTotals struct {
TotalSeconds int64
BreakSeconds int64
}
// ComputeDailyTotals calculates the total work and break time from a list of events.
// It sorts events chronologically, inserts synthetic start/end events when needed,
// and tracks state transitions between idle, working, and on-break.
func ComputeDailyTotals(events []db.Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals {
if len(events) == 0 {
return DailyTotals{}
}
sorted := make([]db.Event, len(events))
copy(sorted, events)
sort.Slice(sorted, func(i, j int) bool {
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 workStart, breakStart int64
state := StateIdle
for _, e := range sorted {
switch e.EventType {
case "in":
switch state {
case StateIdle:
workStart = e.OccurredAt
state = StateWorking
case StateOnBreak:
breakDuration := e.OccurredAt - breakStart
if minBreakThreshold > 0 && breakDuration <= minBreakThreshold {
workStart = breakStart
} else {
breakTotal += breakDuration
workStart = e.OccurredAt
}
state = StateWorking
}
case "out":
switch state {
case StateWorking:
workTotal += e.OccurredAt - workStart
workStart = 0
breakStart = e.OccurredAt
state = StateOnBreak
case StateIdle:
breakStart = e.OccurredAt
state = StateOnBreak
case StateOnBreak:
breakStart = e.OccurredAt
}
}
}
return DailyTotals{
TotalSeconds: workTotal,
BreakSeconds: breakTotal,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
package domain
import (
"fmt"
"math"
)
// ComputeTrendFromAvgs is a pure version of the trend computation for testing.
func ComputeTrendFromAvgs(recentAvg, olderAvg float64) (pts int, direction string) {
if olderAvg < 1 {
if recentAvg > 0 {
return 15, "new"
}
return 5, "stable"
}
changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100)
if recentAvg > olderAvg*1.2 {
return 15, fmt.Sprintf("+%.0f%%", changePct)
}
if olderAvg > recentAvg*1.2 {
return 0, fmt.Sprintf("%.0f%%", changePct)
}
return 5, "stable"
}

View File

@@ -0,0 +1,19 @@
package domain
import "time"
const (
DateLayout = "2006-01-02"
TimeLayout = "15:04"
SecondsPerHour = 3600
SecondsPerDay = 86400
DefaultBreakThreshold = 300
MaxBreakThresholdMins = 480
StreakBonusCap = 500
XPCurveMultiplier = 50
SalaryRoundFactor = 100
MaxHourlyRate = 999999
RateLimitInterval = 1 * time.Second
MaxNoteLength = 500
MaxUsernameLength = 64
)

326
internal/domain/dateutil.go Normal file
View File

@@ -0,0 +1,326 @@
package domain
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
}
const jalaliEpochJDN = 1948320
func GregorianToJDN(gy, gm, gd int) int {
a := (14 - gm) / 12
y := gy + 4800 - a
m := gm + 12*a - 3
return gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
}
func JDNToGregorian(jdn int) (int, int, int) {
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 GregorianToJalali(gy, gm, gd int) (int, int, int) {
jdn := GregorianToJDN(gy, gm, gd)
days := jdn - jalaliEpochJDN
if days < 0 {
return 0, 0, 0
}
jy := 1
for {
yearDays := 365
if IsJalaliLeap(jy) {
yearDays = 366
}
if days < yearDays {
break
}
days -= yearDays
jy++
}
jm := 1
for mm := 0; mm < 12; mm++ {
md := MonthLenJalali(jy, mm+1)
if days < md {
break
}
days -= md
jm++
}
jd := days + 1
return jy, jm, jd
}
func JalaliToGregorian(jy, jm, jd int) (int, int, int) {
days := 0
for y := 1; y < jy; y++ {
if IsJalaliLeap(y) {
days += 366
} else {
days += 365
}
}
for m := 1; m < jm; m++ {
days += MonthLenJalali(jy, m)
}
days += jd - 1
jdn := jalaliEpochJDN + days
return JDNToGregorian(jdn)
}
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) {
jdn := GregorianToJDN(gy, gm, gd)
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 {
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
}
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)
startOffset := dow
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 = 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 + 5) % 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)
startOffset := (dow + 5) % 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 ParseGregorianDateKey(key string) (int, int, int) {
var y, m, d int
fmt.Sscanf(key, "%04d-%02d-%02d", &y, &m, &d)
return y, m, d
}
func FormatDateForCalendar(date, calType string) string {
y, m, d := ParseGregorianDateKey(date)
switch calType {
case "jalali":
jy, jm, jd := GregorianToJalali(y, m, d)
return fmt.Sprintf("%04d-%02d-%02d", jy, jm, jd)
case "hijri":
hy, hm, hd := GregorianToHijri(y, m, d)
return fmt.Sprintf("%04d-%02d-%02d", hy, hm, hd)
default:
return date
}
}
func UserDateToGregorian(dateStr, cal string) string {
y, m, d := ParseGregorianDateKey(dateStr)
switch cal {
case "jalali":
y, m, d = JalaliToGregorian(y, m, d)
case "hijri":
y, m, d = HijriToGregorian(y, m, d)
}
return fmt.Sprintf("%04d-%02d-%02d", y, m, d)
}
func MonthGregorianRange(cal string, year, month int) (start, end string) {
switch cal {
case "jalali":
gy, gm, gd := JalaliToGregorian(year, month, 1)
start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
lastDay := MonthLenJalali(year, month)
gy, gm, gd = JalaliToGregorian(year, month, lastDay)
end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
case "hijri":
gy, gm, gd := HijriToGregorian(year, month, 1)
start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
lastDay := MonthLenHijri(year, month)
gy, gm, gd = HijriToGregorian(year, month, lastDay)
end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
default:
start = fmt.Sprintf("%04d-%02d-%02d", year, month, 1)
lastDay := MonthLenGreg(year, month)
end = fmt.Sprintf("%04d-%02d-%02d", year, month, lastDay)
}
return
}
func FormatMonthTitle(cal string, year, month int) string {
switch cal {
case "jalali":
return fmt.Sprintf("%s %d", JalaliMonthNames[month-1], year)
case "hijri":
return fmt.Sprintf("%s %d", HijriMonthNames[month-1], year)
default:
return fmt.Sprintf("%s %d", GregMonthNames[month-1], year)
}
}
func NavMonth(year, month int) (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++
}
return
}

View File

@@ -0,0 +1,238 @@
package domain
import (
"testing"
)
func TestGregorianToJDN_RoundTrip(t *testing.T) {
cases := []struct{ y, m, d int }{
{2024, 1, 1},
{2024, 12, 31},
{1970, 1, 1},
{1, 1, 1},
{2024, 2, 29},
{1900, 2, 28},
{2000, 2, 29},
}
for _, c := range cases {
jdn := GregorianToJDN(c.y, c.m, c.d)
y, m, d := JDNToGregorian(jdn)
if y != c.y || m != c.m || d != c.d {
t.Errorf("round-trip %04d-%02d-%02d -> JDN %d -> %04d-%02d-%02d", c.y, c.m, c.d, jdn, y, m, d)
}
}
}
func TestGregorianToJalali_RoundTrip(t *testing.T) {
cases := []struct{ gy, gm, gd int }{
{2024, 3, 20},
{2024, 3, 21},
{2024, 9, 22},
{2025, 3, 20},
{1979, 2, 11},
{622, 3, 22},
{2024, 12, 31},
{2024, 6, 21},
{2024, 1, 1},
}
for _, c := range cases {
jy, jm, jd := GregorianToJalali(c.gy, c.gm, c.gd)
if jy == 0 {
t.Errorf("GregorianToJalali(%d, %d, %d) returned 0", c.gy, c.gm, c.gd)
continue
}
gy2, gm2, gd2 := JalaliToGregorian(jy, jm, jd)
if gy2 != c.gy || gm2 != c.gm || gd2 != c.gd {
t.Errorf("round-trip Gregorian %04d-%02d-%02d -> Jalali %04d-%02d-%02d -> Gregorian %04d-%02d-%02d",
c.gy, c.gm, c.gd, jy, jm, jd, gy2, gm2, gd2)
}
}
}
func TestGregorianToHijri_RoundTrip(t *testing.T) {
cases := []struct{ gy, gm, gd int }{
{2024, 3, 21},
{2024, 1, 1},
{2024, 12, 31},
{1979, 2, 11},
{622, 7, 16},
{2024, 7, 7},
{2024, 6, 21},
{2000, 1, 1},
}
for _, c := range cases {
hy, hm, hd := GregorianToHijri(c.gy, c.gm, c.gd)
if hy == 0 {
t.Errorf("GregorianToHijri(%d, %d, %d) returned 0", c.gy, c.gm, c.gd)
continue
}
gy2, gm2, gd2 := HijriToGregorian(hy, hm, hd)
diff := (gy2-c.gy)*365 + (gm2-c.gm)*30 + (gd2 - c.gd)
if diff < 0 {
diff = -diff
}
if diff > 1 {
t.Errorf("round-trip off by %d days: Gregorian %04d-%02d-%02d -> Hijri %04d-%02d-%02d -> Gregorian %04d-%02d-%02d",
diff, c.gy, c.gm, c.gd, hy, hm, hd, gy2, gm2, gd2)
}
}
}
func TestIsLeapYear(t *testing.T) {
gregLeaps := map[int]bool{
2000: true, 2020: true, 2024: true, 1900: false, 2100: false, 2023: false,
}
for y, want := range gregLeaps {
if got := IsGregorianLeap(y); got != want {
t.Errorf("IsGregorianLeap(%d) = %v, want %v", y, got, want)
}
}
jalaliLeaps := map[int]bool{
1: true, 5: true, 9: true, 13: true, 17: true, 22: true, 26: true, 30: true,
2: false, 33: false, 1403: true,
}
for y, want := range jalaliLeaps {
if got := IsJalaliLeap(y); got != want {
t.Errorf("IsJalaliLeap(%d) = %v, want %v", y, got, want)
}
}
hijriLeaps := map[int]bool{
2: true, 5: true, 7: true, 10: true, 13: true, 16: true,
18: true, 21: true, 24: true, 27: true, 29: true,
1: false, 3: false, 30: false,
}
for y, want := range hijriLeaps {
if got := IsHijriLeap(y); got != want {
t.Errorf("IsHijriLeap(%d) = %v, want %v", y, got, want)
}
}
}
func TestMonthLengths(t *testing.T) {
if got := MonthLenGreg(2024, 2); got != 29 {
t.Errorf("MonthLenGreg(2024, 2) = %d, want 29", got)
}
if got := MonthLenGreg(2023, 2); got != 28 {
t.Errorf("MonthLenGreg(2023, 2) = %d, want 28", got)
}
if got := MonthLenGreg(2024, 1); got != 31 {
t.Errorf("MonthLenGreg(2024, 1) = %d, want 31", got)
}
if got := MonthLenJalali(1, 12); got != 30 {
t.Errorf("MonthLenJalali(1, 12) = %d, want 30 (leap)", got)
}
if got := MonthLenJalali(2, 12); got != 29 {
t.Errorf("MonthLenJalali(2, 12) = %d, want 29 (non-leap)", got)
}
if got := MonthLenJalali(1403, 1); got != 31 {
t.Errorf("MonthLenJalali(1403, 1) = %d, want 31", got)
}
if got := MonthLenHijri(1, 1); got != 30 {
t.Errorf("MonthLenHijri(1, 1) = %d, want 30 (odd month)", got)
}
if got := MonthLenHijri(1, 2); got != 29 {
t.Errorf("MonthLenHijri(1, 2) = %d, want 29 (even non-leap)", got)
}
if got := MonthLenHijri(2, 12); got != 30 {
t.Errorf("MonthLenHijri(2, 12) = %d, want 30 (leap year, month 12)", got)
}
}
func TestParseGregorianDateKey(t *testing.T) {
y, m, d := ParseGregorianDateKey("2024-03-21")
if y != 2024 || m != 3 || d != 21 {
t.Errorf("ParseGregorianDateKey got %d-%d-%d, want 2024-3-21", y, m, d)
}
}
func TestFormatDateForCalendar(t *testing.T) {
if got := FormatDateForCalendar("2024-03-21", "gregorian"); got != "2024-03-21" {
t.Errorf("FormatDateForCalendar gregorian: got %s", got)
}
if got := FormatDateForCalendar("2024-03-21", "jalali"); got != "1403-01-02" {
t.Errorf("FormatDateForCalendar jalali: got %s, want 1403-01-02", got)
}
if got := FormatDateForCalendar("2024-03-21", "hijri"); got != "1445-09-11" {
t.Errorf("FormatDateForCalendar hijri: got %s, want 1445-09-11", got)
}
}
func TestUserDateToGregorian(t *testing.T) {
if got := UserDateToGregorian("1403-01-02", "jalali"); got != "2024-03-21" {
t.Errorf("UserDateToGregorian jalali: got %s, want 2024-03-21", got)
}
if got := UserDateToGregorian("1445-09-11", "hijri"); got != "2024-03-21" {
t.Errorf("UserDateToGregorian hijri: got %s, want 2024-03-21", got)
}
}
func TestMonthGregorianRange(t *testing.T) {
start, end := MonthGregorianRange("gregorian", 2024, 1)
if start != "2024-01-01" || end != "2024-01-31" {
t.Errorf("gregorian range: got %s - %s", start, end)
}
start, end = MonthGregorianRange("jalali", 1403, 1)
if start != "2024-03-20" || end != "2024-04-19" {
t.Errorf("jalali range: got %s - %s", start, end)
}
start, end = MonthGregorianRange("hijri", 1445, 9)
if start != "2024-03-11" || end != "2024-04-09" {
t.Errorf("hijri range: got %s - %s", start, end)
}
}
func TestBuildCalendarMonth(t *testing.T) {
cm := BuildCalendarMonth("gregorian", 2024, 1)
if cm.Title != "January 2024" {
t.Errorf("title: got %q", cm.Title)
}
if len(cm.WeekDays) != 7 {
t.Errorf("weekDays count: %d", len(cm.WeekDays))
}
if len(cm.Days) < 28 {
t.Errorf("too few days: %d", len(cm.Days))
}
cm2 := BuildCalendarMonth("jalali", 1403, 1)
if cm2.Title != "Farvardin 1403" {
t.Errorf("jalali title: got %q", cm2.Title)
}
if len(cm2.Days) < 31 {
t.Errorf("too few jalali days: %d", len(cm2.Days))
}
cm3 := BuildCalendarMonth("hijri", 1445, 9)
if cm3.Title != "Ramadan 1445" {
t.Errorf("hijri title: got %q", cm3.Title)
}
}
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)
}
})
}
}

76
internal/domain/rpg.go Normal file
View File

@@ -0,0 +1,76 @@
package domain
import "time"
// XpForLevel returns the total XP required to reach level n.
func XpForLevel(n int) int64 {
return int64(n) * int64(n+1) * XPCurveMultiplier
}
// LevelForXP returns the level for a given total XP amount.
func LevelForXP(xp int64) int {
l := 1
for XpForLevel(l) <= xp {
l++
}
return l - 1
}
// XpForWorkSeconds calculates XP earned for a given amount of work.
func XpForWorkSeconds(sec int64) int64 {
return sec
}
// StreakBonus returns bonus XP for maintaining a streak.
func StreakBonus(streak int) int64 {
b := int64(streak) * XPCurveMultiplier
if b > StreakBonusCap {
b = StreakBonusCap
}
return b
}
// ComputeStreakFromDates computes current and longest streak from a sorted list of date strings.
func ComputeStreakFromDates(dates []string, today string) (current, longest int) {
if len(dates) == 0 {
return 0, 0
}
dateSet := make(map[string]struct{}, len(dates))
for _, d := range dates {
dateSet[d] = struct{}{}
}
if _, ok := dateSet[today]; ok {
current = 1
for i := 1; ; i++ {
t, _ := time.Parse(DateLayout, today)
prev := t.AddDate(0, 0, -i).Format(DateLayout)
if _, ok := dateSet[prev]; ok {
current++
} else {
break
}
}
}
longest = 0
run := 1
for i := 1; i < len(dates); i++ {
prev, _ := time.Parse(DateLayout, dates[i-1])
curr, _ := time.Parse(DateLayout, dates[i])
if curr.Sub(prev).Hours() == 24 {
run++
} else {
if run > longest {
longest = run
}
run = 1
}
}
if run > longest {
longest = run
}
return current, longest
}

View File

@@ -1,4 +1,4 @@
package bot
package domain
import (
"fmt"
@@ -20,9 +20,9 @@ func TestXpForLevel(t *testing.T) {
{50, 127500},
}
for _, c := range cases {
got := xpForLevel(c.n)
got := XpForLevel(c.n)
if got != c.want {
t.Errorf("xpForLevel(%d) = %d, want %d", c.n, got, c.want)
t.Errorf("XpForLevel(%d) = %d, want %d", c.n, got, c.want)
}
}
}
@@ -43,32 +43,32 @@ func TestLevelForXP(t *testing.T) {
{127500, 50},
}
for _, c := range cases {
got := levelForXP(c.xp)
got := LevelForXP(c.xp)
if got != c.want {
t.Errorf("levelForXP(%d) = %d, want %d", c.xp, got, c.want)
t.Errorf("LevelForXP(%d) = %d, want %d", c.xp, got, c.want)
}
}
}
func TestLevelForXP_Monotonic(t *testing.T) {
for n := 0; n <= 100; n++ {
xp := xpForLevel(n)
l := levelForXP(xp)
xp := XpForLevel(n)
l := LevelForXP(xp)
if l != n {
t.Errorf("levelForXP(xpForLevel(%d)) = %d, want %d", n, l, n)
t.Errorf("LevelForXP(XpForLevel(%d)) = %d, want %d", n, l, n)
}
}
}
func TestXpForWorkSeconds(t *testing.T) {
if got := xpForWorkSeconds(0); got != 0 {
t.Errorf("xpForWorkSeconds(0) = %d, want 0", got)
if got := XpForWorkSeconds(0); got != 0 {
t.Errorf("XpForWorkSeconds(0) = %d, want 0", got)
}
if got := xpForWorkSeconds(3600); got != 3600 {
t.Errorf("xpForWorkSeconds(3600) = %d, want 3600", got)
if got := XpForWorkSeconds(3600); got != 3600 {
t.Errorf("XpForWorkSeconds(3600) = %d, want 3600", got)
}
if got := xpForWorkSeconds(1800); got != 1800 {
t.Errorf("xpForWorkSeconds(1800) = %d, want 1800", got)
if got := XpForWorkSeconds(1800); got != 1800 {
t.Errorf("XpForWorkSeconds(1800) = %d, want 1800", got)
}
}
@@ -84,9 +84,9 @@ func TestStreakBonus(t *testing.T) {
{20, 500},
}
for _, c := range cases {
got := streakBonus(c.streak)
got := StreakBonus(c.streak)
if got != c.want {
t.Errorf("streakBonus(%d) = %d, want %d", c.streak, got, c.want)
t.Errorf("StreakBonus(%d) = %d, want %d", c.streak, got, c.want)
}
}
}
@@ -99,67 +99,59 @@ func TestCurrencySymbol(t *testing.T) {
{"", "$"},
{"$", "$"},
{"$ USD", "$"},
{" EUR", ""},
{"£", "£"},
{"\u20AC EUR", "\u20AC"},
{"\u00A3", "\u00A3"},
{"Toman", "Toman"},
{" ", "$"},
}
for _, c := range cases {
got := currencySymbol(c.currency)
got := CurrencySymbol(c.currency)
if got != c.want {
t.Errorf("currencySymbol(%q) = %q, want %q", c.currency, got, c.want)
t.Errorf("CurrencySymbol(%q) = %q, want %q", c.currency, got, c.want)
}
}
}
func TestComputeDailySalary(t *testing.T) {
// 1 hour at $25/hr
got := computeDailySalary(3600, 25.0)
got := ComputeDailySalary(3600, 25.0)
if got != 25.0 {
t.Errorf("computeDailySalary(3600, 25) = %.2f, want 25.00", got)
t.Errorf("ComputeDailySalary(3600, 25) = %.2f, want 25.00", got)
}
// 2.5 hours at $20/hr = $50
got = computeDailySalary(9000, 20.0)
got = ComputeDailySalary(9000, 20.0)
if got != 50.0 {
t.Errorf("computeDailySalary(9000, 20) = %.2f, want 50.00", got)
t.Errorf("ComputeDailySalary(9000, 20) = %.2f, want 50.00", got)
}
// 0 seconds = $0
got = computeDailySalary(0, 50.0)
got = ComputeDailySalary(0, 50.0)
if got != 0 {
t.Errorf("computeDailySalary(0, 50) = %.2f, want 0", got)
t.Errorf("ComputeDailySalary(0, 50) = %.2f, want 0", got)
}
// rounding: 3599 sec (0.9997 hr) at $10/hr = $9.997 -> $10.00
got = computeDailySalary(3599, 10.0)
got = ComputeDailySalary(3599, 10.0)
if got != 10.0 {
t.Errorf("computeDailySalary(3599, 10) = %.2f, want 10.00", got)
t.Errorf("ComputeDailySalary(3599, 10) = %.2f, want 10.00", got)
}
}
func TestSanitizeNote(t *testing.T) {
// Trims spaces
if got := SanitizeNote(" hello "); got != "hello" {
t.Errorf("SanitizeNote trim: got %q", got)
}
// Removes control chars except newline/tab
if got := SanitizeNote("he\x00llo\nworld\t!"); got != "hello\nworld\t!" {
t.Errorf("SanitizeNote control: got %q", got)
}
// Preserves newlines and tabs
if got := SanitizeNote("line1\nline2\tindented"); got != "line1\nline2\tindented" {
t.Errorf("SanitizeNote newline/tab: got %q", got)
}
// Truncates long notes
long := string(make([]byte, 600))
long := make([]byte, 600)
for i := range long {
long = long[:i] + "a" + long[i+1:]
long[i] = 'a'
}
got := SanitizeNote(long)
got := SanitizeNote(string(long))
if len(got) > 500 {
t.Errorf("SanitizeNote length: got %d, want <= 500", len(got))
}
@@ -186,19 +178,19 @@ func TestParseCurrencyInput(t *testing.T) {
{"$ USD", "$ USD", true},
{"T Toman", "T Toman", true},
{"IRR Rial", "IRR Rial", true},
{" EUR", " EUR", true},
{"\u20AC EUR", "\u20AC EUR", true},
{"", "", false},
{"$", "", false},
{" ", "", false},
{"$$$ TooLongCode", "", false},
}
for _, c := range cases {
got, errMsg := parseCurrencyInput(c.input)
got, errMsg := ParseCurrencyInput(c.input)
if c.ok && (got != c.want || errMsg != "") {
t.Errorf("parseCurrencyInput(%q) = (%q, %q), want (%q, \"\")", c.input, got, errMsg, c.want)
t.Errorf("ParseCurrencyInput(%q) = (%q, %q), want (%q, \"\")", c.input, got, errMsg, c.want)
}
if !c.ok && errMsg == "" {
t.Errorf("parseCurrencyInput(%q) = (%q, \"\"), want error", c.input, got)
t.Errorf("ParseCurrencyInput(%q) = (%q, \"\"), want error", c.input, got)
}
}
}
@@ -217,18 +209,18 @@ func TestComputeTrendFromAvgs(t *testing.T) {
{100, 130, 0, "-23"},
}
for _, c := range cases {
pts, dir := computeTrendFromAvgs(c.recent, c.older)
pts, dir := ComputeTrendFromAvgs(c.recent, c.older)
if pts != c.wantPts {
t.Errorf("computeTrendFromAvgs(%.0f, %.0f) pts = %d, want %d", c.recent, c.older, pts, c.wantPts)
t.Errorf("ComputeTrendFromAvgs(%.0f, %.0f) pts = %d, want %d", c.recent, c.older, pts, c.wantPts)
}
if !strings.Contains(dir, c.wantDirSub) {
t.Errorf("computeTrendFromAvgs(%.0f, %.0f) dir = %q, want containing %q", c.recent, c.older, dir, c.wantDirSub)
t.Errorf("ComputeTrendFromAvgs(%.0f, %.0f) dir = %q, want containing %q", c.recent, c.older, dir, c.wantDirSub)
}
}
}
func TestComputeStreakFromDates_Empty(t *testing.T) {
cur, longest := computeStreakFromDates(nil, "2026-06-25")
cur, longest := ComputeStreakFromDates(nil, "2026-06-25")
if cur != 0 || longest != 0 {
t.Errorf("empty dates: got (%d, %d), want (0, 0)", cur, longest)
}
@@ -236,7 +228,7 @@ func TestComputeStreakFromDates_Empty(t *testing.T) {
func TestComputeStreakFromDates_CurrentOnly(t *testing.T) {
dates := []string{"2026-06-25", "2026-06-26"}
cur, longest := computeStreakFromDates(dates, "2026-06-26")
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
if cur != 2 {
t.Errorf("current streak = %d, want 2", cur)
}
@@ -247,7 +239,7 @@ func TestComputeStreakFromDates_CurrentOnly(t *testing.T) {
func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) {
dates := []string{"2026-06-23", "2026-06-25", "2026-06-26"}
cur, longest := computeStreakFromDates(dates, "2026-06-26")
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
if cur != 2 {
t.Errorf("current streak = %d, want 2 (today and yesterday)", cur)
}
@@ -258,7 +250,7 @@ func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) {
func TestComputeStreakFromDates_NoEventsToday(t *testing.T) {
dates := []string{"2026-06-24", "2026-06-25"}
cur, longest := computeStreakFromDates(dates, "2026-06-26")
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
if cur != 0 {
t.Errorf("current streak = %d, want 0 (no events today)", cur)
}
@@ -268,11 +260,11 @@ func TestComputeStreakFromDates_NoEventsToday(t *testing.T) {
}
func TestComputeStreakFromDates_LongStreak(t *testing.T) {
dates := []string{}
dates := make([]string, 0, 10)
for d := 1; d <= 10; d++ {
dates = append(dates, fmt.Sprintf("2026-06-%02d", d))
}
cur, longest := computeStreakFromDates(dates, "2026-06-10")
cur, longest := ComputeStreakFromDates(dates, "2026-06-10")
if cur != 10 {
t.Errorf("current streak = %d, want 10", cur)
}
@@ -284,10 +276,10 @@ func TestComputeStreakFromDates_LongStreak(t *testing.T) {
func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) {
dates := []string{}
for d := 1; d <= 5; d++ {
dates = append(dates, fmt.Sprintf("2026-06-%02d", d))
dates = append(dates, formatDateInt(2026, 6, d))
}
dates = append(dates, "2026-06-09", "2026-06-10")
cur, longest := computeStreakFromDates(dates, "2026-06-10")
cur, longest := ComputeStreakFromDates(dates, "2026-06-10")
if cur != 2 {
t.Errorf("current streak = %d, want 2", cur)
}
@@ -295,3 +287,21 @@ func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) {
t.Errorf("longest streak = %d, want 5", longest)
}
}
func formatDateInt(y, m, d int) string {
return FormatDateForCalendar(
func() string {
s := make([]byte, 10)
s[0] = byte('0' + y/1000)
s[1] = byte('0' + (y/100)%10)
s[2] = byte('0' + (y/10)%10)
s[3] = byte('0' + y%10)
s[4] = '-'
s[5] = byte('0' + m/10)
s[6] = byte('0' + m%10)
s[7] = '-'
s[8] = byte('0' + d/10)
s[9] = byte('0' + d%10)
return string(s)
}(), "gregorian")
}

51
internal/domain/salary.go Normal file
View File

@@ -0,0 +1,51 @@
package domain
import (
"fmt"
"math"
"strings"
"unicode"
)
// ComputeDailySalary calculates salary for a single day's work.
func ComputeDailySalary(workSeconds int64, hourlyRate float64) float64 {
hours := float64(workSeconds) / SecondsPerHour
return math.Round(hours*hourlyRate*SalaryRoundFactor) / SalaryRoundFactor
}
// CurrencySymbol extracts the first token as symbol, defaulting to "$".
func CurrencySymbol(currency string) string {
if parts := strings.Fields(currency); len(parts) >= 1 && parts[0] != "" {
return parts[0]
}
return "$"
}
// FormatSalary formats a salary estimate into a readable string.
func (e SalaryEstimate) FormatSalary(currency string) string {
sym := CurrencySymbol(currency)
code := "USD"
if parts := strings.Fields(currency); len(parts) >= 2 {
code = parts[1]
}
return fmt.Sprintf("%s%.2f %s", sym, e.GrossEarning, code)
}
// FormatSalaryShort returns just the symbol + amount.
func (e SalaryEstimate) FormatSalaryShort(currency string) string {
return fmt.Sprintf("%s%.2f", CurrencySymbol(currency), e.GrossEarning)
}
// ParseCurrencyInput validates and normalizes currency input (symbol + code).
func ParseCurrencyInput(s string) (string, string) {
parts := strings.Fields(s)
if len(parts) < 2 {
return "", "Please provide both symbol and code.\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial"
}
for _, p := range parts[:2] {
if len(p) > 10 || strings.ContainsFunc(p, unicode.IsControl) {
return "", "Invalid currency. Use short printable strings like $ USD, T Toman, IRR Rial."
}
}
return parts[0] + " " + parts[1], ""
}

View File

@@ -1,19 +1,16 @@
package bot
package domain
import (
"strings"
"unicode"
)
const maxNoteLength = 500
const maxUsernameLength = 64
// SanitizeNote cleans user-provided note text: trims whitespace, limits length,
// and removes control characters (keeps newlines).
func SanitizeNote(s string) string {
s = strings.TrimSpace(s)
if len(s) > maxNoteLength {
s = s[:maxNoteLength]
if len(s) > MaxNoteLength {
s = s[:MaxNoteLength]
}
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r == ' ' {
@@ -29,8 +26,8 @@ func SanitizeNote(s string) string {
// SanitizeDisplayName cleans a display name (username) for safe rendering.
func SanitizeDisplayName(s string) string {
s = strings.TrimSpace(s)
if len(s) > maxUsernameLength {
s = s[:maxUsernameLength]
if len(s) > MaxUsernameLength {
s = s[:MaxUsernameLength]
}
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {

68
internal/domain/totals.go Normal file
View File

@@ -0,0 +1,68 @@
package domain
import "sort"
// ComputeDailyTotals calculates the total work and break time from a list of events.
func ComputeDailyTotals(events []Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals {
if len(events) == 0 {
return DailyTotals{}
}
sorted := make([]Event, len(events))
copy(sorted, events)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].OccurredAt < sorted[j].OccurredAt
})
var totalWork, totalBreak int64
state := StateIdle
var workStart, breakStart int64
if dayStart > 0 {
state = StateWorking
workStart = dayStart
} else if sorted[0].EventType == "out" {
state = StateOnBreak
}
for _, e := range sorted {
switch {
case e.EventType == "in" && state == StateIdle:
state = StateWorking
workStart = e.OccurredAt
case e.EventType == "in" && state == StateOnBreak:
breakElapsed := e.OccurredAt - breakStart
if breakElapsed >= minBreakThreshold {
totalBreak += breakElapsed
} else {
totalWork += breakElapsed
}
state = StateWorking
workStart = e.OccurredAt
case e.EventType == "in" && state == StateWorking:
case e.EventType == "out" && state == StateWorking:
totalWork += e.OccurredAt - workStart
state = StateOnBreak
breakStart = e.OccurredAt
case e.EventType == "out" && state == StateOnBreak:
breakStart = e.OccurredAt
case e.EventType == "out" && state == StateIdle:
state = StateOnBreak
breakStart = e.OccurredAt
}
}
if state == StateWorking && dayEnd > 0 {
totalWork += dayEnd - workStart
}
if state == StateOnBreak && dayEnd > 0 {
breakElapsed := dayEnd - breakStart
if breakElapsed >= minBreakThreshold {
totalBreak += breakElapsed
} else {
totalWork += breakElapsed
}
}
return DailyTotals{TotalSeconds: totalWork, BreakSeconds: totalBreak}
}

View File

@@ -1,13 +1,11 @@
package bot
package domain
import (
"testing"
"worktimeBot/internal/db"
)
func mkEvent(typ string, ts int64) db.Event {
return db.Event{EventType: typ, OccurredAt: ts}
func mkEvent(typ string, ts int64) Event {
return Event{EventType: typ, OccurredAt: ts}
}
func TestComputeDailyTotals_Empty(t *testing.T) {
@@ -18,14 +16,14 @@ func TestComputeDailyTotals_Empty(t *testing.T) {
}
func TestComputeDailyTotals_NoEvents(t *testing.T) {
got := ComputeDailyTotals([]db.Event{}, 0, 0, 0)
got := ComputeDailyTotals([]Event{}, 0, 0, 0)
if got.TotalSeconds != 0 || got.BreakSeconds != 0 {
t.Errorf("no events: got %+v", got)
}
}
func TestComputeDailyTotals_SingleInOut(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
}
@@ -39,12 +37,11 @@ func TestComputeDailyTotals_SingleInOut(t *testing.T) {
}
func TestComputeDailyTotals_MissingFirstIn(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("out", 200),
mkEvent("in", 300),
mkEvent("out", 400),
}
// synthetic "in" at dayStart=100 => in(100), out(200)=100s work, break(200-300), in(300), out(400)=100s work
got := ComputeDailyTotals(events, 0, 100, 500)
if got.TotalSeconds != 200 {
t.Errorf("missing first in: TotalSeconds=%d, want 200", got.TotalSeconds)
@@ -52,12 +49,11 @@ func TestComputeDailyTotals_MissingFirstIn(t *testing.T) {
}
func TestComputeDailyTotals_MissingLastOut(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 300),
}
// synthetic "out" at dayEnd=500 => 100s + 200s
got := ComputeDailyTotals(events, 0, 0, 500)
if got.TotalSeconds != 300 {
t.Errorf("missing last out: TotalSeconds=%d, want 300", got.TotalSeconds)
@@ -65,14 +61,12 @@ func TestComputeDailyTotals_MissingLastOut(t *testing.T) {
}
func TestComputeDailyTotals_BreakUnderThreshold(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 250),
mkEvent("out", 400),
}
// break = 50s, threshold = 60s => break absorbed into work
// work: 100-200(100s), then 200-250 absorbed(50s), 250-400(150s) = 300s
got := ComputeDailyTotals(events, 60, 0, 0)
if got.TotalSeconds != 300 {
t.Errorf("break under threshold: TotalSeconds=%d, want 300", got.TotalSeconds)
@@ -83,13 +77,12 @@ func TestComputeDailyTotals_BreakUnderThreshold(t *testing.T) {
}
func TestComputeDailyTotals_BreakOverThreshold(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 300),
mkEvent("out", 400),
}
// break = 100s, threshold = 60s => counted as break
got := ComputeDailyTotals(events, 60, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("break over threshold: TotalSeconds=%d, want 200", got.TotalSeconds)
@@ -100,17 +93,13 @@ func TestComputeDailyTotals_BreakOverThreshold(t *testing.T) {
}
func TestComputeDailyTotals_ConsecutiveOuts(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("out", 250),
mkEvent("in", 300),
mkEvent("out", 400),
}
// in(100)..out(200)=100s work, state=OnBreak breakStart=200
// out(250) while OnBreak => reset breakStart=250
// in(300) => state=Working, workStart=300
// out(400) => work=100s
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("consecutive outs: TotalSeconds=%d, want 200", got.TotalSeconds)
@@ -118,12 +107,11 @@ func TestComputeDailyTotals_ConsecutiveOuts(t *testing.T) {
}
func TestComputeDailyTotals_ConsecutiveIns(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
mkEvent("in", 150),
mkEvent("out", 300),
}
// second "in" while already working is ignored
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("consecutive ins: TotalSeconds=%d, want 200", got.TotalSeconds)
@@ -131,10 +119,9 @@ func TestComputeDailyTotals_ConsecutiveIns(t *testing.T) {
}
func TestComputeDailyTotals_SingleInEndOfDay(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
}
// missing out => synthetic out at dayEnd
got := ComputeDailyTotals(events, 0, 0, 500)
if got.TotalSeconds != 400 {
t.Errorf("single in with dayEnd: TotalSeconds=%d, want 400", got.TotalSeconds)
@@ -142,10 +129,9 @@ func TestComputeDailyTotals_SingleInEndOfDay(t *testing.T) {
}
func TestComputeDailyTotals_SingleOutStartOfDay(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("out", 200),
}
// missing in => synthetic in at dayStart
got := ComputeDailyTotals(events, 0, 100, 500)
if got.TotalSeconds != 100 {
t.Errorf("single out with dayStart: TotalSeconds=%d, want 100", got.TotalSeconds)
@@ -153,14 +139,12 @@ func TestComputeDailyTotals_SingleOutStartOfDay(t *testing.T) {
}
func TestComputeDailyTotals_ZeroThreshold(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 250),
mkEvent("out", 400),
}
// threshold = 0, any break is counted
// work: 100-200(100s) + 250-400(150s) = 250s, break: 200-250(50s)
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 250 {
t.Errorf("zero threshold: TotalSeconds=%d, want 250", got.TotalSeconds)
@@ -171,7 +155,7 @@ func TestComputeDailyTotals_ZeroThreshold(t *testing.T) {
}
func TestComputeDailyTotals_LargeBreak(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 10000),
@@ -187,7 +171,7 @@ func TestComputeDailyTotals_LargeBreak(t *testing.T) {
}
func TestComputeDailyTotals_MultipleSessions(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 300),
@@ -195,7 +179,6 @@ func TestComputeDailyTotals_MultipleSessions(t *testing.T) {
mkEvent("in", 500),
mkEvent("out", 600),
}
// 3 work sessions: 100+100+100 = 300s
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 300 {
t.Errorf("multiple sessions: TotalSeconds=%d, want 300", got.TotalSeconds)
@@ -206,10 +189,9 @@ func TestComputeDailyTotals_MultipleSessions(t *testing.T) {
}
func TestComputeDailyTotals_OnlyOut(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("out", 200),
}
// no dayStart => no synthetic in => idle -> out triggers OnBreak
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 0 {
t.Errorf("only out no dayStart: TotalSeconds=%d, want 0", got.TotalSeconds)
@@ -217,10 +199,9 @@ func TestComputeDailyTotals_OnlyOut(t *testing.T) {
}
func TestComputeDailyTotals_OnlyIn(t *testing.T) {
events := []db.Event{
events := []Event{
mkEvent("in", 100),
}
// no dayEnd => no synthetic out => still working at end
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 0 {
t.Errorf("only in no dayEnd: TotalSeconds=%d, want 0", got.TotalSeconds)

188
internal/domain/types.go Normal file
View File

@@ -0,0 +1,188 @@
package domain
// User represents a bot user with identity, timezone, and calendar preferences.
type User struct {
ID int64
ChatID int64
Username string
Timezone string
IsActive bool
Calendar string
CreatedAt int64
UpdatedAt int64
}
// UserSettings holds all user-configurable preferences.
type UserSettings struct {
UserID int64
ExportAccent string
ReportEnabled bool
ReportTime string
LastReportDate string
RPGEnabled bool
LeagueOptIn bool
Currency string
HourlyRate float64
CreatedAt int64
UpdatedAt int64
}
// RPGStats holds the user's RPG progression data.
type RPGStats struct {
UserID int64
XP int64
Level int
CurrentStreak int
LongestStreak int
TotalWorkSeconds int64
CreatedAt int64
UpdatedAt int64
}
// Achievement is a predefined achievement definition.
type Achievement struct {
ID int64
Code string
Name string
Description string
Icon string
}
// UserAchievement records when a user unlocked an achievement.
type UserAchievement struct {
ID int64
UserID int64
AchievementID int64
Code string
Name string
Description string
UnlockedAt int64
}
// DailyXP records XP earned per day per user.
type DailyXP struct {
ID int64
UserID int64
Date string
XPEarned int64
WorkSeconds int64
CreatedAt int64
}
// WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day.
type WorkType struct {
ID int64
Name string
CreatedAt int64
}
// Day represents a single day entry for a user, including day-off status and work type.
type Day struct {
ID int64
UserID int64
Date string
IsDayOff bool
DayOffReason string
CurrentWorkTypeID int64
MinBreakThreshold int64
CreatedAt int64
UpdatedAt int64
}
// Event represents a single timestamped event ("in" or "out") for a user on a given day.
type Event struct {
ID int64
UserID int64
DayID int64
EventType string
WorkTypeID *int64
OccurredAt int64
Note string
CreatedAt int64
}
// LeagueEntry holds a user's league ranking data.
type LeagueEntry struct {
UserID int64
Username string
XP int64
Level int
Streak int
TotalHours float64
}
// SalaryEstimate holds a salary calculation result.
type SalaryEstimate struct {
Currency string
HourlyRate float64
WorkSeconds int64
WorkHours float64
GrossEarning float64
WorkDays int
}
// BurnoutLevel represents the severity of burnout indicators.
type BurnoutLevel int
const (
BurnoutHealthy BurnoutLevel = 0
BurnoutMild BurnoutLevel = 1
BurnoutModerate BurnoutLevel = 2
BurnoutHigh BurnoutLevel = 3
)
func (b BurnoutLevel) String() string {
switch b {
case BurnoutHealthy:
return "Healthy"
case BurnoutMild:
return "Mild concern"
case BurnoutModerate:
return "Moderate concern"
case BurnoutHigh:
return "High concern"
default:
return "Unknown"
}
}
// BurnoutResult holds the full burnout assessment.
type BurnoutResult struct {
Score int
Level BurnoutLevel
ConsecutivePts int
LongHoursPts int
BreakRatioPts int
WeekendPts int
TrendPts int
LateNightPts int
ConsecutiveDays int
WorkSeconds int64
BreakSeconds int64
TrendDirection string
}
// DailyTotals holds the computed total work and break seconds for a single day.
type DailyTotals struct {
TotalSeconds int64
BreakSeconds int64
}
// State represents the current tracking state during daily totals computation.
type State int
const (
StateIdle State = iota
StateWorking
StateOnBreak
)
// LeagueSortOption represents a valid league sort column.
type LeagueSortOption string
const (
LeagueSortXP LeagueSortOption = "xp"
LeagueSortLevel LeagueSortOption = "level"
LeagueSortStreak LeagueSortOption = "streak"
LeagueSortHours LeagueSortOption = "hours"
)

207
internal/handler/burnout.go Normal file
View File

@@ -0,0 +1,207 @@
package handler
import (
"context"
"fmt"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Command --
func (h *Handler) handleBurnout(ctx context.Context, msg *models.Message, user *domain.User) {
text := h.buildBurnoutText(user)
h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard())
}
// -- Callback --
func (h *Handler) burnoutCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildBurnoutText(user)
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Assessment --
func (h *Handler) buildBurnoutText(user *domain.User) string {
res, err := h.assessBurnout(user)
if err != nil {
return "Burnout assessment unavailable."
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Burnout Assessment\nLevel: %s (%d/100)\n\n", res.Level, res.Score))
if res.ConsecutiveDays > 0 {
b.WriteString(fmt.Sprintf(" Consecutive workdays: %d days (%d pts)\n", res.ConsecutiveDays, res.ConsecutivePts))
}
if res.WorkSeconds > 0 {
wh := float64(res.WorkSeconds) / domain.SecondsPerHour
b.WriteString(fmt.Sprintf(" Today's work: %.1f hours (%d pts)\n", wh, res.LongHoursPts))
}
if res.BreakSeconds > 0 {
br := float64(res.BreakSeconds)
total := float64(res.WorkSeconds + res.BreakSeconds)
ratio := br / total * 100
if ratio > 100 {
ratio = 100
}
b.WriteString(fmt.Sprintf(" Break ratio: %.0f%% (%d pts)\n", ratio, res.BreakRatioPts))
}
if res.WeekendPts > 0 {
b.WriteString(fmt.Sprintf(" Weekend work: yes (%d pts)\n", res.WeekendPts))
}
b.WriteString(fmt.Sprintf(" Work trend: %s (%d pts)\n", res.TrendDirection, res.TrendPts))
if res.LateNightPts > 0 {
b.WriteString(fmt.Sprintf(" Late-night work: yes (%d pts)\n", res.LateNightPts))
}
b.WriteString("\nRecommendations:\n")
switch res.Level {
case domain.BurnoutHealthy:
b.WriteString(" Keep up the balanced routine!")
case domain.BurnoutMild:
b.WriteString(" Consider taking short breaks throughout the day.")
b.WriteString("\n Make sure to disconnect after work hours.")
case domain.BurnoutModerate:
b.WriteString(" Consider taking a day off soon.")
b.WriteString("\n Review your workload distribution.")
b.WriteString("\n Ensure you are taking adequate breaks.")
case domain.BurnoutHigh:
b.WriteString(" Strongly consider taking time off.")
b.WriteString("\n Your workload patterns indicate potential strain.")
b.WriteString("\n Please prioritize rest and recovery.")
}
return b.String()
}
func (h *Handler) assessBurnout(user *domain.User) (*domain.BurnoutResult, error) {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
today := now.Format(domain.DateLayout)
result := &domain.BurnoutResult{}
stats, err := h.Repo.GetOrCreateRPGStats(user.ID)
if err == nil {
result.ConsecutiveDays = stats.CurrentStreak
}
switch {
case result.ConsecutiveDays >= 7:
result.ConsecutivePts = 25
case result.ConsecutiveDays >= 4:
result.ConsecutivePts = 15
case result.ConsecutiveDays >= 1:
result.ConsecutivePts = 5
}
todayEvents, _ := h.Repo.EventsForDayByDate(user.ID, today)
if len(todayEvents) > 0 {
day, _ := h.Repo.GetDay(user.ID, today)
if day != nil {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
totals := domain.ComputeDailyTotals(todayEvents, day.MinBreakThreshold, dayStart, now.Unix())
result.WorkSeconds = totals.TotalSeconds
result.BreakSeconds = totals.BreakSeconds
workHours := float64(totals.TotalSeconds) / domain.SecondsPerHour
switch {
case workHours >= 12:
result.LongHoursPts = 20
case workHours >= 10:
result.LongHoursPts = 15
case workHours >= 8:
result.LongHoursPts = 10
}
total := totals.TotalSeconds + totals.BreakSeconds
if total > 0 {
breakRatio := float64(totals.BreakSeconds) / float64(total) * 100
switch {
case breakRatio < 5:
result.BreakRatioPts = 15
case breakRatio < 10:
result.BreakRatioPts = 10
case breakRatio < 20:
result.BreakRatioPts = 5
}
}
if len(todayEvents) > 0 {
lastEvent := todayEvents[len(todayEvents)-1]
lt := time.Unix(lastEvent.OccurredAt, 0).In(loc)
minutes := lt.Hour()*60 + lt.Minute()
switch {
case minutes >= 22*60:
result.LateNightPts = 10
case minutes >= 20*60:
result.LateNightPts = 5
}
}
}
}
weekday := now.Weekday()
if weekday == time.Saturday || weekday == time.Sunday {
if result.WorkSeconds > 0 {
result.WeekendPts = 15
}
}
result.TrendPts, result.TrendDirection = h.computeTrend(user.ID, today, loc)
result.Score = result.ConsecutivePts + result.LongHoursPts + result.BreakRatioPts +
result.WeekendPts + result.TrendPts + result.LateNightPts
switch {
case result.Score >= 61:
result.Level = domain.BurnoutHigh
case result.Score >= 41:
result.Level = domain.BurnoutModerate
case result.Score >= 21:
result.Level = domain.BurnoutMild
default:
result.Level = domain.BurnoutHealthy
}
return result, nil
}
func (h *Handler) computeTrend(userID int64, today string, loc *time.Location) (int, string) {
t, err := time.Parse(domain.DateLayout, today)
if err != nil {
return 0, "unknown"
}
var recent, older [3]int64
for i := 0; i < 3; i++ {
d := t.AddDate(0, 0, -(i + 1)).Format(domain.DateLayout)
recent[2-i] = h.dayWorkSeconds(userID, d, loc)
}
for i := 0; i < 3; i++ {
d := t.AddDate(0, 0, -(i + 4)).Format(domain.DateLayout)
older[2-i] = h.dayWorkSeconds(userID, d, loc)
}
var recentAvg, olderAvg float64
for _, v := range recent {
recentAvg += float64(v)
}
for _, v := range older {
olderAvg += float64(v)
}
recentAvg /= 3.0
olderAvg /= 3.0
return domain.ComputeTrendFromAvgs(recentAvg, olderAvg)
}
func (h *Handler) dayWorkSeconds(userID int64, date string, loc *time.Location) int64 {
events, err := h.Repo.EventsForDayByDate(userID, date)
if err != nil || len(events) == 0 {
return 0
}
day, err := h.Repo.GetDay(userID, date)
if err != nil || day == nil {
return 0
}
y, m, d := domain.ParseGregorianDateKey(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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
return totals.TotalSeconds
}

View File

@@ -1,4 +1,4 @@
package bot
package handler
import (
"context"
@@ -10,245 +10,37 @@ import (
"github.com/go-telegram/bot/models"
"worktimeBot/internal/db"
"worktimeBot/internal/domain"
)
// handleHistoryMsg shows the calendar view (new message).
func (h *Handler) handleHistoryMsg(ctx context.Context, msg *models.Message) {
h.sendCalendar(ctx, msg.Chat.ID, 0, 0, 0)
// -- History command and callback --
func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, user *domain.User) {
h.editCalendar(ctx, msg.Chat.ID, 0, 0, 0)
}
// historyCallback opens or refreshes the calendar view (inline).
func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.editCalendar(ctx, chatID, msgID, 0, 0)
}
// handleEditMsg parses a /edit YYYY-MM-DD command and shows that day's events.
func (h *Handler) handleEditMsg(ctx context.Context, msg *models.Message) {
date := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/edit"))
if date == "" {
h.sendText(ctx, msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)")
return
}
if len(date) != 10 || date[4] != '-' || date[7] != '-' {
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)")
return
}
_, m, d := parseGregorianDateKey(date)
if m < 1 || m > 12 || d < 1 || d > 31 {
h.sendText(ctx, msg.Chat.ID, "Invalid date.")
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
loc := loadLocation(user.Timezone)
date = userDateToGregorian(date, user.Calendar)
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
}
// -- Calendar rendering --
// handleHistoryCallback routes calendar-related callback data to the right handler.
func (h *Handler) handleHistoryCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
loc := loadLocation(user.Timezone)
if data == "history" {
h.editCalendar(ctx, chatID, msgID, 0, 0)
return
}
if h.handleHistoryCalendarNav(ctx, chatID, msgID, data) {
return
}
if h.handleHistoryYearPicker(ctx, chatID, msgID, data) {
return
}
if h.handleHistoryDayView(ctx, chatID, msgID, user, data, loc) {
return
}
if h.handleHistoryEventEdit(ctx, chatID, msgID, user, data, loc, callbackID) {
return
}
if h.handleHistoryEventAdd(ctx, chatID, msgID, user, data, loc) {
return
}
}
// handleHistoryCalendarNav handles calendar navigation callbacks.
func (h *Handler) handleHistoryCalendarNav(ctx context.Context, chatID int64, msgID int, data string) bool {
var y, m int
if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return true
}
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return true
}
if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return true
}
if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return true
}
return false
}
// handleHistoryYearPicker handles year picker callbacks.
func (h *Handler) handleHistoryYearPicker(ctx context.Context, chatID int64, msgID int, data string) bool {
var y, m, startY, refY, refM int
if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 {
h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y)
return true
}
if n, _ := fmt.Sscanf(data, "cal_years_%d_%d_%d", &startY, &refY, &refM); n == 3 {
h.editCalendarYearPicker(ctx, chatID, msgID, startY+6, refM, refY)
return true
}
if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 {
h.editCalendar(ctx, chatID, msgID, y, 1)
return true
}
if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return true
}
return false
}
// handleHistoryDayView handles day-view-related callbacks.
func (h *Handler) handleHistoryDayView(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location) bool {
var date string
if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 {
h.editDayView(ctx, chatID, msgID, user, date, loc)
return true
}
var eid int64
if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return true
}
return false
}
// handleHistoryEventEdit handles event editing callbacks.
func (h *Handler) handleHistoryEventEdit(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location, callbackID string) bool {
var eid, wtid int64
if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 {
h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc)
return true
}
if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 {
h.editEventType(ctx, chatID, msgID, user, eid, loc)
return true
}
if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 {
h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc)
return true
}
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc)
return true
}
if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 {
h.editEventTime(ctx, chatID, msgID, user, eid, loc)
return true
}
var hh, mm int
if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 {
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc, callbackID)
return true
}
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc)
return true
}
if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 {
h.promptNote(ctx, chatID, msgID, user, eid, loc)
return true
}
if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return true
}
return false
}
// handleHistoryEventAdd handles event-adding callbacks.
func (h *Handler) handleHistoryEventAdd(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location) bool {
switch {
case strings.HasPrefix(data, "addin_"):
h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc)
case strings.HasPrefix(data, "addout_"):
h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc)
case strings.HasPrefix(data, "addtm_"):
h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc)
default:
return false
}
return true
}
// sendCalendar sends a calendar view as a new message.
func (h *Handler) sendCalendar(ctx context.Context, chatID int64, msgID int, year, month int) {
h.editCalendar(ctx, chatID, msgID, year, month)
}
// buildYearPicker returns a 12-year grid keyboard centered on centerYear.
// cbPrefix is "cal_" or "exp_". backData is the callback for the Back button.
// navSuffix is appended to the nav callbacks to carry reference info (e.g. "_2026_3").
func buildYearPicker(cbPrefix, backData, navSuffix string, centerYear int) models.InlineKeyboardMarkup {
blockStart := centerYear - 6
kbRows := [][]models.InlineKeyboardButton{
{
{Text: "<", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart-12, navSuffix)},
{Text: fmt.Sprintf("%d", centerYear), CallbackData: "noop"},
{Text: ">", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart+12, navSuffix)},
},
}
for i := 0; i < 12; i += 4 {
row := []models.InlineKeyboardButton{}
for j := i; j < i+4; j++ {
y := blockStart + j
row = append(row, models.InlineKeyboardButton{
Text: fmt.Sprintf("%d", y), CallbackData: fmt.Sprintf("%syear_%d", cbPrefix, y),
})
}
kbRows = append(kbRows, row)
}
if backData != "" {
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: backData},
})
}
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}
// editCalendar renders a monthly calendar grid with event indicators and navigation.
func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, year, month int) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
loc := loadLocation(user.Timezone)
loc := h.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())
jy, jm, _ := domain.GregorianToJalali(now.Year(), int(now.Month()), now.Day())
year, month = jy, jm
case "hijri":
hy, hm, _ := gregorianToHijri(now.Year(), int(now.Month()), now.Day())
hy, hm, _ := domain.GregorianToHijri(now.Year(), int(now.Month()), now.Day())
year, month = hy, hm
default:
year, month = now.Year(), int(now.Month())
@@ -256,52 +48,47 @@ func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, yea
}
cal := user.Calendar
cm := buildCalendarMonth(cal, year, month)
text := cm.title
todayKey := now.Format(DateLayout)
cm := domain.BuildCalendarMonth(cal, year, month)
text := cm.Title
todayKey := now.Format(domain.DateLayout)
hasEvent := make(map[string]bool)
startDate, endDate := monthGregorianRange(cal, year, month)
dates, err := h.DB.GetDistinctEventDates(user.ID, startDate, endDate)
startDate, endDate := domain.MonthGregorianRange(cal, year, month)
dates, err := h.Repo.GetDistinctEventDates(user.ID, startDate, endDate)
if err == nil {
for _, d := range dates {
hasEvent[d] = true
}
}
kbRows := [][]models.InlineKeyboardButton{}
kbRows := [][]models.InlineKeyboardButton{
{
{Text: "<", CallbackData: fmt.Sprintf("cal_prev_year_%d_%d", year-1, month)},
{Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("cal_show_years_%d_%d", year, month)},
{Text: ">", CallbackData: fmt.Sprintf("cal_next_year_%d_%d", year+1, month)},
},
}
// Year navigation row
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: fmt.Sprint("<"), CallbackData: fmt.Sprintf("cal_prev_year_%d_%d", year-1, month)},
{Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("cal_show_years_%d_%d", year, month)},
{Text: fmt.Sprint(">"), CallbackData: fmt.Sprintf("cal_next_year_%d_%d", year+1, month)},
})
// Weekday header row
headerRow := []models.InlineKeyboardButton{}
for _, wn := range cm.weekDays {
for _, wn := range cm.WeekDays {
headerRow = append(headerRow, models.InlineKeyboardButton{Text: wn, CallbackData: "noop"})
}
kbRows = append(kbRows, headerRow)
// Day cells
row := []models.InlineKeyboardButton{}
for i, d := range cm.days {
if d.dayNum == 0 {
for i, d := range cm.Days {
if d.DayNum == 0 {
row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "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)
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, models.InlineKeyboardButton{Text: label, CallbackData: "cal_day_" + d.date})
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: "cal_day_" + d.Date})
}
if len(row) == 7 || i == len(cm.days)-1 {
// Pad the last row to 7 columns so buttons render evenly
if len(row) == 7 || i == len(cm.Days)-1 {
for len(row) < 7 {
row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"})
}
@@ -310,15 +97,12 @@ func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, yea
}
}
prevY, prevM, nextY, nextM := navMonth(year, month)
// Navigation row
prevY, prevM, nextY, nextM := domain.NavMonth(year, month)
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "<", CallbackData: fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)},
{Text: "Today", CallbackData: "history"},
{Text: ">", CallbackData: fmt.Sprintf("cal_next_%d_%d", nextY, nextM)},
})
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
@@ -327,8 +111,115 @@ func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, yea
h.sendOrEdit(ctx, chatID, msgID, text, &kb)
}
// editCalendarYearPicker shows a 12-year grid for year selection.
// refY/refM encode the view to return to via the Back button.
// -- Calendar callbacks routing --
func (h *Handler) routeHistoryCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
loc := h.loadLocation(user.Timezone)
if data == "history" {
h.editCalendar(ctx, chatID, msgID, 0, 0)
return
}
var y, m int
if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 {
h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y)
return
}
var startY, refY, refM int
if n, _ := fmt.Sscanf(data, "cal_years_%d_%d_%d", &startY, &refY, &refM); n == 3 {
h.editCalendarYearPicker(ctx, chatID, msgID, startY+6, refM, refY)
return
}
if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 {
h.editCalendar(ctx, chatID, msgID, y, 1)
return
}
if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
var date string
if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 {
h.editDayView(ctx, chatID, msgID, user, date, loc)
return
}
var eid int64
if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return
}
var wtid int64
if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 {
h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc)
return
}
if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 {
h.editEventType(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 {
h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 {
h.editEventTime(ctx, chatID, msgID, user, eid, loc)
return
}
var hh, mm int
if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 {
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc, cbID)
return
}
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc)
return
}
if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 {
h.promptNoteInput(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return
}
switch {
case strings.HasPrefix(data, "addin_"):
h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc)
case strings.HasPrefix(data, "addout_"):
h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc)
case strings.HasPrefix(data, "addtm_"):
h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc)
}
}
// -- Calendar year picker --
func (h *Handler) editCalendarYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refM, refY int) {
backData := fmt.Sprintf("cal_years_back_%d_%d", refY, refM)
navSuffix := fmt.Sprintf("_%d_%d", refY, refM)
@@ -336,22 +227,21 @@ func (h *Handler) editCalendarYearPicker(ctx context.Context, chatID int64, msgI
h.editText(ctx, chatID, msgID, "Select year:", &kb)
}
// editDayView opens an existing message as a day view.
func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *db.User, date string, loc *time.Location) {
// -- Day view --
func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, date string, loc *time.Location) {
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}
// sendDayView displays all events for a given date with inline edit/delete buttons.
func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user *db.User, date string, loc *time.Location, isNewMsg bool) {
events, err := h.DB.EventsForDayByDate(user.ID, date)
func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, date string, loc *time.Location, isNewMsg bool) {
events, err := h.Repo.EventsForDayByDate(user.ID, date)
if err != nil {
if isNewMsg {
h.sendText(ctx, chatID, "Error loading events")
}
return
}
text := fmt.Sprintf("Date: %s", formatDateForCalendar(date, user.Calendar))
text := fmt.Sprintf("Date: %s", domain.FormatDateForCalendar(date, user.Calendar))
if len(events) == 0 {
text += "\nNo events for this day."
kb := models.InlineKeyboardMarkup{
@@ -368,14 +258,14 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user
text += "\n\nEvents:"
kbRows := [][]models.InlineKeyboardButton{}
for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format(TimeLayout)
t := time.Unix(e.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
label := "IN"
if e.EventType == "out" {
label = "OUT"
}
wt := ""
if e.WorkTypeID != nil {
if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil {
if wtObj, err := h.Repo.GetWorkType(*e.WorkTypeID); err == nil {
wt = " [" + wtObj.Name + "]"
}
}
@@ -384,7 +274,6 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user
note = " (" + e.Note + ")"
}
text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note)
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: fmt.Sprintf("Time %s", t), CallbackData: fmt.Sprintf("edit_time_%d", e.ID)},
{Text: "Type", CallbackData: fmt.Sprintf("edit_type_%d", e.ID)},
@@ -392,12 +281,10 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user
{Text: "DEL", CallbackData: fmt.Sprintf("delete_%d", e.ID), Style: "danger"},
})
}
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Add IN", CallbackData: "addin_" + date},
{Text: "Add OUT", CallbackData: "addout_" + date},
})
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back to Calendar", CallbackData: "history"},
})
@@ -405,9 +292,10 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user
h.sendOrEdit(ctx, chatID, msgID, text, &kb)
}
// editEventType shows a work type picker for a specific event.
func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
wts, err := h.DB.GetWorkTypes()
// -- Event editing --
func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
wts, err := h.Repo.GetWorkTypes()
if err != nil {
return
}
@@ -425,54 +313,21 @@ func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, us
h.editText(ctx, chatID, msgID, text, &kb)
}
// setEventWorkType updates an event's work type and returns to the day view.
func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int, user *db.User, eventID, workTypeID int64, loc *time.Location) {
if err := h.DB.UpdateEventWorkType(eventID, workTypeID); err != nil {
func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID, workTypeID int64, loc *time.Location) {
if err := h.Repo.UpdateEventWorkType(eventID, workTypeID); err != nil {
slog.Error("failed to update event work type", "event_id", eventID, "error", err)
h.sendDayView(ctx, chatID, msgID, user, "", loc, false)
return
}
event, err := h.getEventByID(user.ID, eventID)
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
h.sendDayView(ctx, chatID, msgID, user, t.Format(DateLayout), loc, false)
h.sendDayView(ctx, chatID, msgID, user, t.Format(domain.DateLayout), loc, false)
}
// buildHourPickerKeyboard creates the 24-hour grid for time selection.
func buildHourPickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup {
kbRows := [][]models.InlineKeyboardButton{}
hourRow := []models.InlineKeyboardButton{}
for hh := 0; hh < 24; hh++ {
hourRow = append(hourRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("%02d", hh), CallbackData: cb(hh),
})
if len(hourRow) == 6 || hh == 23 {
kbRows = append(kbRows, hourRow)
hourRow = nil
}
}
kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}})
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}
// buildMinutePickerKeyboard creates the 15-min interval row for minute selection.
func buildMinutePickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup {
kbRows := [][]models.InlineKeyboardButton{}
minRow := []models.InlineKeyboardButton{}
for _, mm := range []int{0, 10, 20, 30, 40, 50} {
minRow = append(minRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("%02d", mm), CallbackData: cb(mm),
})
}
kbRows = append(kbRows, minRow)
kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}})
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}
// editEventTime shows an hour picker for changing an event's time.
func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
kb := buildHourPickerKeyboard(
func(hh int) string { return fmt.Sprintf("edittm_%d_%02d", eventID, hh) },
fmt.Sprintf("back_day_%d", eventID),
@@ -480,8 +335,7 @@ func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, us
h.editText(ctx, chatID, msgID, "Select hour:", &kb)
}
// editEventTimeMin shows a minute picker (15-min intervals) after hour selection.
func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh int, loc *time.Location) {
func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, hh int, loc *time.Location) {
kb := buildMinutePickerKeyboard(
func(mm int) string { return fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm) },
fmt.Sprintf("edit_time_%d", eventID),
@@ -489,18 +343,16 @@ func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int,
h.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &kb)
}
// editEventTimeSet applies a new time to an event, checking for overlaps.
func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location, callbackID string) {
event, err := h.getEventByID(user.ID, eventID)
func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, hh, mm int, loc *time.Location, cbID string) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
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 := newTime.Format(DateLayout)
date := newTime.Format(domain.DateLayout)
// Reject if the new time collides with another event's timestamp
events, err := h.DB.EventsForDayByDate(user.ID, date)
events, err := h.Repo.EventsForDayByDate(user.ID, date)
if err == nil {
for _, e := range events {
if e.ID == eventID {
@@ -508,13 +360,11 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int,
}
eTime := time.Unix(e.OccurredAt, 0)
if newTime.Unix() == eTime.Unix() {
h.acknowledgeCallback(ctx, callbackID)
h.answerCb(ctx, cbID)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return
}
}
// Validate that in/out events still alternate after the change
type eventPos struct {
id int64
typ string
@@ -528,7 +378,6 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int,
}
sorted = append(sorted, eventPos{e.ID, e.EventType, et})
}
// Bubble sort by timestamp, then by ID for determinism
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) {
@@ -538,14 +387,14 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int,
}
for i := 1; i < len(sorted); i++ {
if sorted[i].typ == sorted[i-1].typ {
h.editText(ctx, chatID, msgID, "Edit rejected: overlapping events. Two consecutive events must be different types (in/out).", nil)
h.editText(ctx, chatID, msgID, "Edit rejected: overlapping events.", nil)
return
}
}
}
if err = h.DB.UpdateEventTime(eventID, newTime.Unix()); err != nil {
slog.Error("failed to update event occurred_at", "event_id", eventID, "error", err)
if err = h.Repo.UpdateEventTime(eventID, newTime.Unix()); err != nil {
slog.Error("failed to update event time", "event_id", eventID, "error", err)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return
}
@@ -554,33 +403,9 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int,
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}
// acknowledgeCallback sends an empty acknowledgement for a callback query.
func (h *Handler) acknowledgeCallback(ctx context.Context, callbackID string) {
h.answerCb(ctx, callbackID)
}
// -- Event deletion --
// promptNote asks the user to type a note for the event, then re-renders the day view.
func (h *Handler) promptNote(ctx context.Context, 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).Format(TimeLayout)
h.mu.Lock()
h.pendingNotes[chatID] = &pendingNote{eventID: eventID, createdAt: time.Now()}
h.mu.Unlock()
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Cancel", CallbackData: fmt.Sprintf("note_cancel_%d", eventID)}},
},
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Send the note for event at %s:", t), &kb)
}
// deleteEventPrompt asks the user to confirm event deletion.
func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
@@ -592,23 +417,21 @@ func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int
h.editText(ctx, chatID, msgID, "Delete this event?", &kb)
}
// deleteEventConfirm deletes an event and warns if the timeline becomes invalid.
func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
event, err := h.getEventByID(user.ID, eventID)
func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
date := t.Format(DateLayout)
date := t.Format(domain.DateLayout)
if err = h.DB.DeleteEvent(eventID); err != nil {
if err = h.Repo.DeleteEvent(eventID); err != nil {
slog.Error("failed to delete event", "event_id", eventID, "error", err)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return
}
// Warn if consecutive events now have the same type
events, err := h.DB.EventsForDayByDate(user.ID, date)
events, err := h.Repo.EventsForDayByDate(user.ID, date)
if err == nil && len(events) > 1 {
hasIssue := false
for i := 1; i < len(events); i++ {
@@ -618,7 +441,7 @@ func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID in
}
}
if hasIssue {
h.sendText(ctx, chatID, "Warning: Timeline has consecutive events of same type after deletion. Manual repair needed.")
h.sendText(ctx, chatID, "Warning: Timeline has consecutive events of same type after deletion.")
}
}
@@ -627,23 +450,36 @@ func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID in
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}
// backToDayView returns from event editing to the day view.
func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
event, err := h.getEventByID(user.ID, eventID)
func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
h.sendDayView(ctx, chatID, msgID, user, t.Format(DateLayout), loc, false)
h.sendDayView(ctx, chatID, msgID, user, t.Format(domain.DateLayout), loc, false)
}
// getEventByID fetches a single event by ID, scoped to the user.
func (h *Handler) getEventByID(userID, eventID int64) (*db.Event, error) {
return h.DB.GetEventByID(eventID, userID)
// -- Note via pending input --
func (h *Handler) promptNoteInput(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Cancel", CallbackData: fmt.Sprintf("note_cancel_%d", eventID)}},
},
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Send the note for event at %s:", t), &kb)
h.setPending(ctx, chatID, msgID, user.ID, PendingNote, map[string]string{"event_id": fmt.Sprintf("%d", eventID)})
}
// addEventTime shows an hour picker for adding a new event to a day.
func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, date, eventType string, loc *time.Location) {
// -- Adding events --
func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, loc *time.Location) {
kb := buildHourPickerKeyboard(
func(hh int) string { return fmt.Sprintf("addtm_%s_%s_%02d", eventType, date, hh) },
"cal_day_"+date,
@@ -651,46 +487,7 @@ func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, use
h.editText(ctx, chatID, msgID, fmt.Sprintf("Select hour for %s event:", eventType), &kb)
}
// addEventTimeMin shows a minute picker (15-min intervals) after hour selection for adding an event.
func (h *Handler) addEventTimeMin(ctx context.Context, chatID int64, msgID int, user *db.User, date, eventType string, hh int, loc *time.Location) {
kb := buildMinutePickerKeyboard(
func(mm int) string { return fmt.Sprintf("addtm_%s_%s_%02d_%02d", eventType, date, hh, mm) },
"add"+eventType+"_"+date,
)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &kb)
}
// addEventDo creates a new event at the specified time and returns to the day view.
func (h *Handler) addEventDo(ctx context.Context, chatID int64, msgID int, user *db.User, date, eventType string, hh, mm int, loc *time.Location) {
t, err := time.ParseInLocation("2006-01-02 15:04", date+" "+fmt.Sprintf("%02d:%02d", hh, mm), loc)
if err != nil {
return
}
day, err := h.DB.GetOrCreateDay(user.ID, date)
if err != nil {
return
}
var wt *int64
if eventType == "in" {
wt = &day.CurrentWorkTypeID
if *wt == 0 {
wt = nil
}
}
if err := h.DB.CreateEvent(user.ID, day.ID, eventType, wt, t.Unix(), ""); err != nil {
return
}
h.recalcDayWorkSeconds(user.ID, date, loc)
h.recalcAggregates(user.ID, loc)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}
// handleAddEventTime parses the addtm_ callback and dispatches to the minute picker or event creation.
func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, payload string, loc *time.Location) {
func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, payload string, loc *time.Location) {
parts := strings.SplitN(payload, "_", 4)
if len(parts) < 3 {
return
@@ -701,7 +498,6 @@ func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID in
if err != nil {
return
}
if len(parts) == 3 {
h.addEventTimeMin(ctx, chatID, msgID, user, addDate, eventType, hh, loc)
return
@@ -712,3 +508,35 @@ func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID in
}
h.addEventDo(ctx, chatID, msgID, user, addDate, eventType, hh, mm, loc)
}
func (h *Handler) addEventTimeMin(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, hh int, loc *time.Location) {
kb := buildMinutePickerKeyboard(
func(mm int) string { return fmt.Sprintf("addtm_%s_%s_%02d_%02d", eventType, date, hh, mm) },
"add"+eventType+"_"+date,
)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &kb)
}
func (h *Handler) addEventDo(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, hh, mm int, loc *time.Location) {
t, err := time.ParseInLocation("2006-01-02 15:04", date+" "+fmt.Sprintf("%02d:%02d", hh, mm), loc)
if err != nil {
return
}
day, err := h.Repo.GetOrCreateDay(user.ID, date)
if err != nil {
return
}
var wt *int64
if eventType == "in" {
wt = &day.CurrentWorkTypeID
if *wt == 0 {
wt = nil
}
}
if err := h.Repo.CreateEvent(user.ID, day.ID, eventType, wt, t.Unix(), ""); err != nil {
return
}
h.recalcDayWorkSeconds(user.ID, date, loc)
h.recalcAggregates(user.ID, loc)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}

222
internal/handler/clock.go Normal file
View File

@@ -0,0 +1,222 @@
package handler
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Commands --
func (h *Handler) handleClockIn(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doClockIn(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleClockOut(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doClockOut(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleDayOff(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doDayOff(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleReport(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doReport(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleReportToggle(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
st.ReportEnabled = !st.ReportEnabled
if err := h.Repo.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving settings")
return
}
status := "disabled"
if st.ReportEnabled {
status = "enabled"
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Daily report %s", status))
}
// -- Callbacks --
func (h *Handler) clockInCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doClockIn(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) clockOutCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doClockOut(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) dayOffCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doDayOff(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) reportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doReport(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Business logic (shared between commands and callbacks) --
func (h *Handler) doClockIn(user *domain.User) (string, error) {
day, err := h.getTodayDay(user)
if err != nil {
return "", err
}
last, err := h.Repo.GetLastEvent(user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
if last != nil && last.EventType == "in" {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
lastTime := time.Unix(last.OccurredAt, 0).In(loc)
if lastTime.Format(domain.DateLayout) == now.Format(domain.DateLayout) {
return "", errors.New("already clocked in")
}
}
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
wt := day.CurrentWorkTypeID
if err := h.Repo.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
return "", err
}
return fmt.Sprintf("Clocked in at %s", now.Format(domain.TimeLayout)), nil
}
func (h *Handler) doClockOut(user *domain.User) (string, error) {
day, err := h.getTodayDay(user)
if err != nil {
return "", err
}
last, err := h.Repo.GetLastEvent(user.ID)
if err != nil {
return "", err
}
if last == nil {
return "", errors.New("no clock-in found - start with /clockin first")
}
if last.EventType == "out" {
return "", errors.New("already clocked out")
}
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
if err := h.Repo.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
return "", err
}
text := fmt.Sprintf("Clocked out at %s", now.Format(domain.TimeLayout))
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err == nil && st.RPGEnabled {
sessionWork := now.Unix() - last.OccurredAt
today := now.Format(domain.DateLayout)
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
stats, err := h.Repo.GetOrCreateRPGStats(user.ID)
if err == nil {
text += h.awardXPAndCheckAchievements(user, stats, sessionWork, today, isWeekend)
}
}
return text, nil
}
func (h *Handler) doDayOff(user *domain.User) (string, error) {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return "", err
}
if day.IsDayOff {
if err := h.Repo.RemoveDayOff(user.ID, day.Date); err != nil {
return "", err
}
return "Day off removed", nil
}
if err := h.Repo.SetDayOff(user.ID, day.Date, ""); err != nil {
return "", err
}
return "Today marked as day off", nil
}
func (h *Handler) doReport(user *domain.User) (string, error) {
day, err := h.getTodayDay(user)
if err != nil {
return "", err
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil {
return "", err
}
return h.buildDailyReport(user, day, events), nil
}
func (h *Handler) getTodayDay(user *domain.User) (*domain.Day, error) {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
return h.Repo.GetOrCreateDay(user.ID, today)
}
func (h *Handler) getTodayBreakThreshold(user *domain.User) int64 {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetDay(user.ID, today)
if err != nil {
return domain.DefaultBreakThreshold
}
return day.MinBreakThreshold
}

View File

@@ -1,4 +1,4 @@
package bot
package handler
import (
"bytes"
@@ -8,11 +8,11 @@ import (
"strings"
"time"
bot "github.com/go-telegram/bot"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/xuri/excelize/v2"
"worktimeBot/internal/db"
"worktimeBot/internal/domain"
)
type themeColors struct {
@@ -92,16 +92,107 @@ func newStyle(f *excelize.File, s *excelize.Style) int {
return id
}
func writeReportHeader(f *excelize.File, sheet, title string, s reportStyles) {
func (h *Handler) generateMonthlyReport(user *domain.User, calYear, calMonth int, start, end time.Time) ([]byte, error) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return nil, err
}
accent := st.ExportAccent
theme, ok := themes[accent]
if !ok {
theme = themes["ocean"]
}
f := excelize.NewFile()
defer f.Close()
sheet := "Report"
index, err := f.NewSheet(sheet)
if err != nil {
return nil, err
}
f.SetActiveSheet(index)
f.DeleteSheet("Sheet1")
styles := newReportStyles(f, theme)
monthName := domain.FormatMonthTitle(user.Calendar, calYear, calMonth)
h.writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles)
loc := h.loadLocation(user.Timezone)
row := 3
userDays, err := h.Repo.GetUserDaysInRange(user.ID, start.Format(domain.DateLayout), end.Format(domain.DateLayout))
if err != nil {
return nil, err
}
dayMap := make(map[string]*domain.Day)
for i := range userDays {
dayMap[userDays[i].Date] = &userDays[i]
}
var totalWork, totalBreak int64
layout := start.Format(domain.DateLayout)
endLayout := end.Format(domain.DateLayout)
cur := start
for !cur.After(end) {
dateStr := cur.Format(domain.DateLayout)
displayDate := domain.FormatDateForCalendar(dateStr, user.Calendar)
var (
day *domain.Day
events []domain.Event
hasDayOff bool
)
if existing, ok := dayMap[dateStr]; ok {
day = existing
events, err = h.Repo.EventsForDayByDayID(day.ID)
if err != nil {
return nil, err
}
hasDayOff = day.IsDayOff
}
var typeLabel string
if hasDayOff {
if day.DayOffReason != "" {
typeLabel = "Day Off (" + day.DayOffReason + ")"
} else {
typeLabel = "Day Off"
}
} else if len(events) > 0 {
typeLabel = h.workTypeLabel(day, events)
}
ws, bs := h.writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles)
totalWork += ws
totalBreak += bs
row++
cur = cur.AddDate(0, 0, 1)
}
_ = layout
_ = endLayout
h.writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles)
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (h *Handler) writeReportHeader(f *excelize.File, sheet, title string, s reportStyles) {
f.SetCellValue(sheet, "A1", title)
f.MergeCell(sheet, "A1", "F1")
f.SetCellStyle(sheet, "A1", "F1", s.title)
f.SetRowHeight(sheet, 1, 30)
headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"}
for i, h := range headers {
for i, hdr := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
f.SetCellValue(sheet, cell, h)
f.SetCellValue(sheet, cell, hdr)
f.SetCellStyle(sheet, cell, cell, s.header)
}
f.SetRowHeight(sheet, 2, 22)
@@ -114,21 +205,21 @@ func writeReportHeader(f *excelize.File, sheet, title string, s reportStyles) {
f.SetColWidth(sheet, "F", "F", 10)
}
func writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typeLabel string, events []db.Event, day *db.Day, loc *time.Location, hasDayOff bool, s reportStyles) (workSec, breakSec int64) {
func (h *Handler) writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typeLabel string, events []domain.Event, day *domain.Day, loc *time.Location, hasDayOff bool, s reportStyles) (workSec, breakSec int64) {
cellDate, _ := excelize.CoordinatesToCellName(1, row)
f.SetCellValue(sheet, cellDate, displayDate)
f.SetCellStyle(sheet, cellDate, cellDate, s.data)
if len(events) > 0 {
cellIn, _ := excelize.CoordinatesToCellName(2, row)
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format(TimeLayout))
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format(domain.TimeLayout))
f.SetCellStyle(sheet, cellIn, cellIn, s.data)
}
if len(events) > 0 {
if len(events) > 1 {
lastEvent := events[len(events)-1]
if lastEvent.EventType == "out" {
cellOut, _ := excelize.CoordinatesToCellName(3, row)
f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format(TimeLayout))
f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format(domain.TimeLayout))
f.SetCellStyle(sheet, cellOut, cellOut, s.data)
}
}
@@ -138,13 +229,14 @@ func writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typ
f.SetCellStyle(sheet, cellType, cellType, s.data)
if len(events) > 0 && day != nil {
var d time.Time
d, _ = time.Parse(DateLayout, day.Date)
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)
workSec = totals.TotalSeconds
breakSec = totals.BreakSeconds
d, err := time.Parse(domain.DateLayout, day.Date)
if err == nil {
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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
workSec = totals.TotalSeconds
breakSec = totals.BreakSeconds
}
}
cellWork, _ := excelize.CoordinatesToCellName(5, row)
@@ -164,7 +256,7 @@ func writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typ
return workSec, breakSec
}
func writeReportTotalRow(f *excelize.File, sheet string, row int, totalWork, totalBreak int64, s reportStyles) {
func (h *Handler) writeReportTotalRow(f *excelize.File, sheet string, row int, totalWork, totalBreak int64, s reportStyles) {
row++
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), s.total)
@@ -174,88 +266,21 @@ func writeReportTotalRow(f *excelize.File, sheet string, row int, totalWork, tot
f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), s.total)
}
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) {
theme, ok := themes[accent]
if !ok {
theme = themes["ocean"]
}
f := excelize.NewFile()
defer f.Close()
sheet := "Report"
index, err := f.NewSheet(sheet)
if err != nil {
return nil, err
}
f.SetActiveSheet(index)
f.DeleteSheet("Sheet1")
styles := newReportStyles(f, theme)
monthName := formatMonthTitle(calendar, calYear, calMonth)
writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles)
loc := loadLocation(timezone)
row := 3
userDays, err := store.GetUserDaysInRange(userID, start.Format(DateLayout), end.Format(DateLayout))
if err != nil {
return nil, err
}
dayMap := make(map[string]*db.Day)
for i := range userDays {
dayMap[userDays[i].Date] = &userDays[i]
}
var totalWork, totalBreak int64
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
dateStr := d.Format(DateLayout)
displayDate := formatDateForCalendar(dateStr, calendar)
var (
day *db.Day
events []db.Event
hasDayOff bool
)
if existing, ok := dayMap[dateStr]; ok {
day = existing
events, err = store.EventsForDayByDayID(day.ID)
if err != nil {
return nil, err
}
hasDayOff = day.IsDayOff
}
var typeLabel string
if hasDayOff {
if day.DayOffReason != "" {
typeLabel = "Day Off (" + day.DayOffReason + ")"
} else {
typeLabel = "Day Off"
}
} else if len(events) > 0 {
typeLabel = workTypeLabel(store, day, events)
}
ws, bs := writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles)
totalWork += ws
totalBreak += bs
row++
}
writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles)
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
func fmtHHMM(seconds int64) string {
hours := seconds / domain.SecondsPerHour
mins := (seconds % domain.SecondsPerHour) / 60
return fmt.Sprintf("%d:%02d", hours, mins)
}
func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
func fmtDDHHMM(seconds int64) string {
days := seconds / domain.SecondsPerDay
rem := seconds % domain.SecondsPerDay
hours := rem / domain.SecondsPerHour
mins := (rem % domain.SecondsPerHour) / 60
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
}
func (h *Handler) workTypeLabel(day *domain.Day, events []domain.Event) string {
wtSeen := make(map[int64]bool)
for _, e := range events {
if e.WorkTypeID != nil {
@@ -263,7 +288,7 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
}
}
if len(wtSeen) == 0 {
wt, err := store.GetWorkType(day.CurrentWorkTypeID)
wt, err := h.Repo.GetWorkType(day.CurrentWorkTypeID)
if err == nil {
return wt.Name
}
@@ -271,7 +296,7 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
}
if len(wtSeen) == 1 {
for id := range wtSeen {
wt, err := store.GetWorkType(id)
wt, err := h.Repo.GetWorkType(id)
if err == nil {
return wt.Name
}
@@ -280,85 +305,64 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
return "mixed"
}
func fmtHHMM(seconds int64) string {
hours := seconds / secondsPerHour
mins := (seconds % secondsPerHour) / 60
return fmt.Sprintf("%d:%02d", hours, mins)
}
// -- Commands --
func fmtDDHHMM(seconds int64) string {
days := seconds / secondsPerDay
rem := seconds % secondsPerDay
hours := rem / secondsPerHour
mins := (rem % secondsPerHour) / 60
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
}
// handleExport processes the /export command, showing the month picker or exporting directly.
func (h *Handler) handleExport(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)
func (h *Handler) handleExport(ctx context.Context, msg *models.Message, user *domain.User) {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
cy, cm := now.Year(), int(now.Month())
calY, calM := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
case "hijri":
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
}
args := strings.Fields(msg.Text)
if len(args) > 0 && args[0][0] == '/' {
if len(args) >= 1 && args[0][0] == '/' {
args = args[1:]
}
if len(args) >= 1 {
if n, _ := fmt.Sscanf(args[0], "%d-%d", &cy, &cm); n == 2 {
if cy < 0 || cm < 1 || cm > 12 {
if n, _ := fmt.Sscanf(args[0], "%d-%d", &calY, &calM); n == 2 {
if calY < 0 || calM < 1 || calM > 12 {
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
return
}
}
}
h.sendExportDirect(ctx, msg.Chat.ID, user, cy, cm)
h.sendExportDirect(ctx, msg.Chat.ID, user, calY, calM)
}
// sendExportDirect generates and sends an Excel report for the given calendar month.
func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.User, calYear, calMonth int) {
last, err := h.DB.GetLastEvent(user.ID)
func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *domain.User, calYear, calMonth int) {
last, err := h.Repo.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
h.sendText(ctx, chatID, "You are currently clocked in. Please clock out first, then try again.")
return
}
loc := loadLocation(user.Timezone)
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
start, err := time.Parse(DateLayout, startStr)
loc := h.loadLocation(user.Timezone)
startStr, endStr := domain.MonthGregorianRange(user.Calendar, calYear, calMonth)
start, err := time.Parse(domain.DateLayout, startStr)
if err != nil {
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
}
end, err := time.Parse(DateLayout, endStr)
end, err := time.Parse(domain.DateLayout, endStr)
if err != nil {
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
end = startOfMonth.AddDate(0, 1, -1)
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
accent := st.ExportAccent
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, accent, user.Calendar, calYear, calMonth, start, end)
data, err := h.generateMonthlyReport(user, calYear, calMonth, start, end)
if err != nil {
slog.Error("generate report", "error", err)
h.sendText(ctx, chatID, "Error generating report")
return
}
slog.Info("report generated", "bytes", len(data))
if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{
if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
@@ -369,37 +373,32 @@ func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.U
}
}
// exportCallback opens the export month picker.
func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
loc := loadLocation(user.Timezone)
// -- Callbacks --
func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
cy, cm := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
cy, cm, _ = domain.GregorianToJalali(cy, cm, now.Day())
case "hijri":
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
cy, cm, _ = domain.GregorianToHijri(cy, cm, now.Day())
}
h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user)
}
// editExportMonthPicker renders a year-based month picker with 12 month buttons.
func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *db.User) {
months := gregMonthNames
func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *domain.User) {
months := domain.GregMonthNames
switch user.Calendar {
case "jalali":
months = jalaliMonthNames
months = domain.JalaliMonthNames
case "hijri":
months = hijriMonthNames
months = domain.HijriMonthNames
}
kbRows := [][]models.InlineKeyboardButton{
// Year navigation
{
{Text: "<", CallbackData: fmt.Sprintf("exp_year_prev_%d", year)},
{Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("exp_show_years_%d", year)},
@@ -407,7 +406,6 @@ func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID
},
}
// 4 columns x 3 rows of month buttons
for i := 0; i < 12; i += 4 {
row := []models.InlineKeyboardButton{}
for j := i; j < i+4 && j < 12; j++ {
@@ -423,50 +421,18 @@ func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID
})
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
if msgID == 0 {
h.sendWithKB(ctx, chatID, "Select month to export:", kb)
} else {
h.editText(ctx, chatID, msgID, "Select month to export:", &kb)
}
h.sendOrEdit(ctx, chatID, msgID, "Select month to export:", &kb)
}
// editExportYearPicker shows a 12-year grid for year selection in the export flow.
// refY is the year to return to via the Back button.
func (h *Handler) editExportYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refY int) {
backData := fmt.Sprintf("exp_years_back_%d", refY)
navSuffix := fmt.Sprintf("_%d", refY)
kb := buildYearPicker("exp_", backData, navSuffix, centerYear)
h.editText(ctx, chatID, msgID, "Select year:", &kb)
}
// formatMonthTitle returns the localized month name and year for a given calendar.
func formatMonthTitle(cal string, year, month int) string {
switch cal {
case "jalali":
return fmt.Sprintf("%s %d", jalaliMonthNames[month-1], year)
case "hijri":
return fmt.Sprintf("%s %d", hijriMonthNames[month-1], year)
default:
return fmt.Sprintf("%s %d", gregMonthNames[month-1], year)
}
}
// handleExportCallback routes export inline button presses (year nav, month select).
func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
defer h.answerCb(ctx, cbID)
if h.handleExportNav(ctx, chatID, msgID, user, data) {
return
}
h.handleExportDo(ctx, chatID, msgID, user, data)
}
// handleExportNav handles year/month navigation callbacks for the export flow.
func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *db.User, data string) bool {
func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *domain.User, data string) bool {
var y int
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user)
@@ -496,14 +462,13 @@ func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int,
return false
}
// handleExportDo triggers the actual Excel export for a selected month.
func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *db.User, data string) {
func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *domain.User, data string) {
var y, m int
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 {
return
}
last, err := h.DB.GetLastEvent(user.ID)
last, err := h.Repo.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
backKB := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
@@ -514,21 +479,20 @@ func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, u
return
}
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
start, err := time.Parse(DateLayout, startStr)
startStr, endStr := domain.MonthGregorianRange(user.Calendar, y, m)
start, err := time.Parse(domain.DateLayout, startStr)
if err != nil {
h.sendText(ctx, chatID, "Error processing date")
return
}
end, err := time.Parse(DateLayout, endStr)
end, err := time.Parse(domain.DateLayout, endStr)
if err != nil {
h.sendText(ctx, chatID, "Error processing date")
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
reportData, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, st.ExportAccent, user.Calendar, y, m, start, end)
reportData, err := h.generateMonthlyReport(user, y, m, start, end)
if err != nil {
slog.Error("generate report", "error", err)
backKB := models.InlineKeyboardMarkup{
@@ -548,7 +512,7 @@ func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, u
}
h.editText(ctx, chatID, msgID, "Report ready:", &backKB)
if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{
if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
@@ -558,3 +522,10 @@ func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, u
slog.Error("send document", "error", err)
}
}
func (h *Handler) editExportYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refY int) {
backData := fmt.Sprintf("exp_years_back_%d", refY)
navSuffix := fmt.Sprintf("_%d", refY)
kb := buildYearPicker("exp_", backData, navSuffix, centerYear)
h.editText(ctx, chatID, msgID, "Select year:", &kb)
}

453
internal/handler/handler.go Normal file
View File

@@ -0,0 +1,453 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
"worktimeBot/internal/repo"
)
const rateLimitInterval = 1 * time.Second
type PendingKind string
const (
PendingNote PendingKind = "note"
PendingRate PendingKind = "rate"
PendingCurrency PendingKind = "currency"
PendingTimezone PendingKind = "timezone"
PendingBreak PendingKind = "break_threshold"
PendingUsername PendingKind = "username"
PendingReportTime PendingKind = "report_time"
)
type PendingInput struct {
Kind PendingKind
UserID int64
CreatedAt time.Time
MsgID int
Data map[string]string
}
type Handler struct {
Bot *tgbot.Bot
Repo repo.Repository
AllowedUsers map[int64]bool
lastMsgTime map[int64]time.Time
pendingInput map[int64]*PendingInput
mu sync.Mutex
}
func NewHandler(bot *tgbot.Bot, store repo.Repository, allowed map[int64]bool) *Handler {
h := &Handler{
Bot: bot,
Repo: store,
AllowedUsers: allowed,
lastMsgTime: make(map[int64]time.Time),
pendingInput: make(map[int64]*PendingInput),
}
go h.periodicCleanup()
return h
}
func (h *Handler) periodicCleanup() {
for {
time.Sleep(10 * time.Second)
h.mu.Lock()
cutoff := time.Now().Add(-rateLimitInterval * 2)
for uid, t := range h.lastMsgTime {
if t.Before(cutoff) {
delete(h.lastMsgTime, uid)
}
}
pendingCutoff := time.Now().Add(-5 * time.Minute)
for chatID, pi := range h.pendingInput {
if pi.CreatedAt.Before(pendingCutoff) {
delete(h.pendingInput, chatID)
}
}
h.mu.Unlock()
}
}
func (h *Handler) isRateLimited(userID int64) bool {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < rateLimitInterval {
return true
}
h.lastMsgTime[userID] = now
return false
}
func (h *Handler) getOrCreateUser(chatID int64) (*domain.User, error) {
return h.Repo.GetOrCreateUser(chatID)
}
func (h *Handler) loadLocation(tz string) *time.Location {
loc, err := time.LoadLocation(tz)
if err != nil {
return time.UTC
}
return loc
}
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
}
if h.isRateLimited(user.ID) {
return
}
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
if msg.Text[0] == '/' {
h.mu.Lock()
delete(h.pendingInput, msg.Chat.ID)
h.mu.Unlock()
h.routeCommand(ctx, msg, user)
return
}
h.mu.Lock()
pi, hasPI := h.pendingInput[msg.Chat.ID]
if hasPI {
delete(h.pendingInput, msg.Chat.ID)
}
h.mu.Unlock()
if hasPI {
h.processPendingInput(ctx, msg, user, pi)
return
}
h.sendText(ctx, msg.Chat.ID, "Unknown command")
}
func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, user *domain.User) {
cmd := msg.Text
if i := strings.IndexByte(msg.Text, ' '); i >= 0 {
cmd = msg.Text[:i]
}
switch cmd {
case "/start":
h.handleStart(ctx, msg, user)
case "/help":
h.handleHelp(ctx, msg)
case "/clockin":
h.handleClockIn(ctx, msg, user)
case "/clockout":
h.handleClockOut(ctx, msg, user)
case "/worktype":
h.handleWorkType(ctx, msg, user)
case "/report":
h.handleReport(ctx, msg, user)
case "/export":
h.handleExport(ctx, msg, user)
case "/dayoff":
h.handleDayOff(ctx, msg, user)
case "/history":
h.handleHistory(ctx, msg, user)
case "/edit":
h.handleEdit(ctx, msg, user)
case "/reporttoggle":
h.handleReportToggle(ctx, msg, user)
case "/setreporttime":
h.handleSetReportTime(ctx, msg, user)
case "/setaccent":
h.handleSetAccent(ctx, msg, user)
case "/settimezone":
h.handleSetTimezone(ctx, msg, user)
case "/note":
h.handleNote(ctx, msg, user)
case "/summary":
h.handleSummary(ctx, msg, user)
case "/setbreak":
h.handleSetBreak(ctx, msg, user)
case "/rpg":
h.handleRPG(ctx, msg, user)
case "/achievements":
h.handleAchievements(ctx, msg, user)
case "/salary":
h.handleSalary(ctx, msg, user)
case "/burnout":
h.handleBurnout(ctx, msg, user)
case "/league":
h.handleLeague(ctx, msg, user)
case "/setrate":
h.handleSetRate(ctx, msg, user)
case "/setcurrency":
h.handleSetCurrency(ctx, msg, user)
case "/rpgtoggle":
h.handleRPGSettings(ctx, msg, user)
case "/leaguetoggle":
h.handleLeagueToggle(ctx, msg, user)
case "/setusername":
h.handleSetUsername(ctx, msg, user)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown command")
}
}
func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) {
chatID := cb.Message.Message.Chat.ID
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[chatID] {
h.answerCbWithText(ctx, cb.ID, "Unauthorized")
return
}
user, err := h.getOrCreateUser(chatID)
if err != nil {
h.answerCb(ctx, cb.ID)
return
}
if h.isRateLimited(user.ID) {
h.answerCb(ctx, cb.ID)
return
}
slog.Info("callback", "chat_id", chatID, "data", cb.Data)
h.mu.Lock()
delete(h.pendingInput, chatID)
h.mu.Unlock()
msgID := cb.Message.Message.ID
data := cb.Data
switch {
case data == "pending_cancel":
h.backToMenu(ctx, chatID, msgID, cb.ID)
case data == "clockin":
h.clockInCallback(ctx, chatID, msgID, cb.ID, user)
case data == "clockout":
h.clockOutCallback(ctx, chatID, msgID, cb.ID, user)
case data == "dayoff":
h.dayOffCallback(ctx, chatID, msgID, cb.ID, user)
case data == "report":
h.reportCallback(ctx, chatID, msgID, cb.ID, user)
case data == "worktype":
h.workTypeCallback(ctx, chatID, msgID, cb.ID, user)
case data == "export":
h.exportCallback(ctx, chatID, msgID, cb.ID, user)
case data == "settings":
h.settingsCallback(ctx, chatID, msgID, cb.ID, user)
case data == "history":
h.historyCallback(ctx, chatID, msgID, cb.ID, user)
case data == "noop":
h.answerCb(ctx, cb.ID)
case data == "rpg":
h.rpgCallback(ctx, chatID, msgID, cb.ID, user)
case data == "achievements":
h.achievementsCallback(ctx, chatID, msgID, cb.ID, user)
case data == "salary":
h.salaryCallback(ctx, chatID, msgID, cb.ID, user)
case data == "burnout":
h.burnoutCallback(ctx, chatID, msgID, cb.ID, user)
case data == "league":
h.leagueCallback(ctx, chatID, msgID, cb.ID, user, string(domain.LeagueSortXP))
case data == "back_menu":
h.backToMenu(ctx, chatID, msgID, cb.ID)
default:
h.routePrefixedCallback(ctx, chatID, msgID, cb.ID, user, data)
}
}
func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
switch {
case strings.HasPrefix(data, "worktype_"):
h.selectWorkType(ctx, chatID, msgID, cbID, user, data[9:])
case strings.HasPrefix(data, "accent_"):
h.selectAccent(ctx, chatID, msgID, cbID, user, data[7:])
case strings.HasPrefix(data, "caltype_"):
h.selectCalendar(ctx, chatID, msgID, cbID, user, data[8:])
case strings.HasPrefix(data, "exp_"):
h.handleExportCallback(ctx, chatID, msgID, cbID, user, data)
case strings.HasPrefix(data, "breakthreshold_"):
h.selectBreakThreshold(ctx, chatID, msgID, cbID, user, data[15:])
case strings.HasPrefix(data, "currency_"):
h.selectCurrency(ctx, chatID, msgID, cbID, user, data[9:])
case data == "rpg_enable":
h.setRPGCallback(ctx, chatID, msgID, cbID, user, true)
case data == "rpg_disable":
h.setRPGCallback(ctx, chatID, msgID, cbID, user, false)
case data == "league_enable":
h.setLeagueCallback(ctx, chatID, msgID, cbID, user, true)
case data == "league_disable":
h.setLeagueCallback(ctx, chatID, msgID, cbID, user, false)
case data == "report_enable":
h.setReportToggleCallback(ctx, chatID, msgID, cbID, user, true)
case data == "report_disable":
h.setReportToggleCallback(ctx, chatID, msgID, cbID, user, false)
case data == "back_settings":
h.backToSettings(ctx, chatID, msgID, cbID, user)
case data == "rpgtoggle":
h.rpgToggleCallback(ctx, chatID, msgID, cbID, user)
case data == "leaguetoggle":
h.leagueToggleCallback(ctx, chatID, msgID, cbID, user)
case data == "reporttoggle":
h.reportToggleCallback(ctx, chatID, msgID, cbID, user)
case data == "rpg_achievements":
h.rpgAchievementsCallback(ctx, chatID, msgID, cbID, user)
case data == "rpg_back":
h.rpgBackCallback(ctx, chatID, msgID, cbID, user)
case data == "username":
h.usernameCallback(ctx, chatID, msgID, cbID, user)
case data == "currency":
h.currencyCallback(ctx, chatID, msgID, cbID, user)
case data == "setrate":
h.rateCallback(ctx, chatID, msgID, cbID, user)
case data == "timezone":
h.timezoneCallback(ctx, chatID, msgID, cbID, user)
case data == "caltype":
h.calTypeCallback(ctx, chatID, msgID, cbID, user)
case data == "accent":
h.accentCallback(ctx, chatID, msgID, cbID, user)
case data == "reporttime":
h.reportTimeCallback(ctx, chatID, msgID, cbID, user)
case data == "breakthreshold":
h.breakThresholdCallback(ctx, chatID, msgID, cbID, user)
case strings.HasPrefix(data, "league_"):
sortBy := data[7:]
h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy)
default:
h.routeHistoryCallback(ctx, chatID, msgID, cbID, user, data)
}
}
func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, data map[string]string) {
h.mu.Lock()
h.pendingInput[chatID] = &PendingInput{
Kind: kind,
UserID: userID,
CreatedAt: time.Now(),
MsgID: msgID,
Data: data,
}
h.mu.Unlock()
}
func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput) {
text := strings.TrimSpace(msg.Text)
switch pi.Kind {
case PendingNote:
h.processNoteInput(ctx, msg, user, pi, text)
case PendingRate:
h.processRateInput(ctx, msg, user, pi, text)
case PendingCurrency:
h.processCurrencyInput(ctx, msg, user, pi, text)
case PendingTimezone:
h.processTimezoneInput(ctx, msg, user, pi, text)
case PendingBreak:
h.processBreakInput(ctx, msg, user, pi, text)
case PendingUsername:
h.processUsernameInput(ctx, msg, user, pi, text)
case PendingReportTime:
h.processReportTimeInput(ctx, msg, user, pi, text)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown input type")
}
}
func (h *Handler) promptForInput(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, prompt string, data map[string]string) {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Cancel", CallbackData: "pending_cancel"}},
},
}
if msgID == 0 {
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: chatID, Text: prompt, ReplyMarkup: kb,
})
if err != nil {
slog.Warn("send prompt failed", "chat_id", chatID, "error", err)
return
}
h.setPending(ctx, chatID, msg.ID, userID, kind, data)
} else {
h.editText(ctx, chatID, msgID, prompt, &kb)
h.setPending(ctx, chatID, msgID, userID, kind, data)
}
}
// sendOrEdit sends a new message or edits an existing one depending on msgID.
func (h *Handler) sendOrEdit(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
if msgID == 0 {
if kb != nil {
h.sendWithKB(ctx, chatID, text, *kb)
} else {
h.sendText(ctx, chatID, text)
}
} else {
h.editText(ctx, chatID, msgID, text, kb)
}
}
// -- helpers --
func (h *Handler) sendText(ctx context.Context, chatID int64, text string) {
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil {
slog.Warn("send text failed", "chat_id", chatID, "error", err)
}
}
func (h *Handler) editText(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
p := &tgbot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text}
if kb != nil {
p.ReplyMarkup = kb
}
if _, err := h.Bot.EditMessageText(ctx, p); err != nil {
slog.Warn("edit text failed", "chat_id", chatID, "msg_id", msgID, "error", err)
}
}
func (h *Handler) sendWithKB(ctx context.Context, chatID int64, text string, kb models.InlineKeyboardMarkup) {
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb}); err != nil {
slog.Warn("send with keyboard failed", "chat_id", chatID, "error", err)
}
}
func (h *Handler) answerCb(ctx context.Context, callbackID string) {
if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil {
slog.Debug("answer callback failed", "error", err)
}
}
func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string) {
if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID, Text: text}); err != nil {
slog.Debug("answer callback with text failed", "error", err)
}
}
func (h *Handler) tryUnlockAchievement(userID int64, code string) {
a, err := h.Repo.GetAchievementByCode(code)
if err != nil {
return
}
if _, err := h.Repo.UnlockAchievement(userID, a.ID); err != nil {
slog.Error("unlock achievement", "code", code, "error", err)
}
}
func formatDuration(seconds int64) string {
hours := seconds / domain.SecondsPerHour
mins := (seconds % domain.SecondsPerHour) / 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}

View File

@@ -0,0 +1,111 @@
package handler
import (
"fmt"
"github.com/go-telegram/bot/models"
)
func mainKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Clock In", CallbackData: "clockin"},
{Text: "Clock Out", CallbackData: "clockout"},
},
{
{Text: "Work Type", CallbackData: "worktype"},
{Text: "Day Off", CallbackData: "dayoff"},
},
{
{Text: "Report", CallbackData: "report"},
{Text: "Export", CallbackData: "export"},
{Text: "History", CallbackData: "history"},
},
{
{Text: "RPG", CallbackData: "rpg"},
{Text: "League", CallbackData: "league"},
{Text: "Burnout", CallbackData: "burnout"},
},
{
{Text: "Salary", CallbackData: "salary"},
{Text: "Settings", CallbackData: "settings"},
},
},
}
}
func backKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
}
func settingsBackKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
}
// buildYearPicker returns a 12-year grid keyboard centered on centerYear.
func buildYearPicker(cbPrefix, backData, navSuffix string, centerYear int) models.InlineKeyboardMarkup {
blockStart := centerYear - 6
kbRows := [][]models.InlineKeyboardButton{
{
{Text: "<", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart-12, navSuffix)},
{Text: fmt.Sprintf("%d", centerYear), CallbackData: "noop"},
{Text: ">", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart+12, navSuffix)},
},
}
for i := 0; i < 12; i += 4 {
row := []models.InlineKeyboardButton{}
for j := i; j < i+4; j++ {
y := blockStart + j
row = append(row, models.InlineKeyboardButton{
Text: fmt.Sprintf("%d", y), CallbackData: fmt.Sprintf("%syear_%d", cbPrefix, y),
})
}
kbRows = append(kbRows, row)
}
if backData != "" {
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: backData},
})
}
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}
// buildHourPickerKeyboard creates the 24-hour grid for time selection.
func buildHourPickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup {
kbRows := [][]models.InlineKeyboardButton{}
hourRow := []models.InlineKeyboardButton{}
for hh := 0; hh < 24; hh++ {
hourRow = append(hourRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("%02d", hh), CallbackData: cb(hh),
})
if len(hourRow) == 6 || hh == 23 {
kbRows = append(kbRows, hourRow)
hourRow = nil
}
}
kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}})
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}
// buildMinutePickerKeyboard creates the 15-min interval row for minute selection.
func buildMinutePickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup {
kbRows := [][]models.InlineKeyboardButton{}
minRow := []models.InlineKeyboardButton{}
for _, mm := range []int{0, 10, 20, 30, 40, 50} {
minRow = append(minRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("%02d", mm), CallbackData: cb(mm),
})
}
kbRows = append(kbRows, minRow)
kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}})
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}

View File

@@ -1,4 +1,4 @@
package bot
package handler
import (
"context"
@@ -6,37 +6,58 @@ import (
"strings"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// LeagueSortOption represents a valid league sort column.
type LeagueSortOption string
// -- Command --
const (
LeagueSortXP LeagueSortOption = "xp"
LeagueSortLevel LeagueSortOption = "level"
LeagueSortStreak LeagueSortOption = "streak"
LeagueSortHours LeagueSortOption = "hours"
)
func (h *Handler) handleLeague(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.LeagueOptIn {
h.sendText(ctx, msg.Chat.ID, "League participation is disabled.\nEnable it in Settings or use /leaguetoggle.")
return
}
text := h.buildLeagueText(domain.LeagueSortXP)
kb := h.buildLeagueKeyboard(string(domain.LeagueSortXP))
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// buildLeagueText returns the formatted league rankings (mobile-friendly narrow layout).
func (h *Handler) buildLeagueText(orderBy LeagueSortOption) string {
entries, err := h.DB.GetLeagueRankings(string(orderBy))
// -- Callback --
func (h *Handler) leagueCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, orderBy string) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if !st.LeagueOptIn {
kb := backKeyboard()
h.editText(ctx, chatID, msgID, "League participation is disabled.\nEnable it in Settings to view rankings.", &kb)
return
}
text := h.buildLeagueText(domain.LeagueSortOption(orderBy))
kb := h.buildLeagueKeyboard(orderBy)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Builders --
func (h *Handler) buildLeagueText(orderBy domain.LeagueSortOption) string {
entries, err := h.Repo.GetLeagueRankings(string(orderBy))
if err != nil {
return "League rankings unavailable."
}
if len(entries) == 0 {
return "No participants in the league yet.\nEnable League participation in Settings to join!"
}
var b strings.Builder
orderLabel := map[string]string{
"xp": "XP",
"level": "Level",
"streak": "Streak",
"hours": "Hours",
}
b.WriteString(fmt.Sprintf("WorkTime League — %s\n\n", orderLabel[string(orderBy)]))
orderLabel := map[string]string{"xp": "XP", "level": "Level", "streak": "Streak", "hours": "Hours"}
b.WriteString(fmt.Sprintf("WorkTime League - %s\n\n", orderLabel[string(orderBy)]))
for i, e := range entries {
rank := i + 1
name := e.Username
@@ -46,19 +67,15 @@ func (h *Handler) buildLeagueText(orderBy LeagueSortOption) string {
b.WriteString(fmt.Sprintf("%d %s\n", rank, name))
b.WriteString(fmt.Sprintf(" XP:%d Lv:%d S:%d %.1fh\n", e.XP, e.Level, e.Streak, e.TotalHours))
}
b.WriteString(fmt.Sprintf("\n%d participant(s)\n", len(entries)))
return b.String()
}
// buildLeagueRankText returns the user's personal rank in the league.
func (h *Handler) buildLeagueRankText(userID int64) string {
entries, err := h.DB.GetLeagueRankings("xp")
entries, err := h.Repo.GetLeagueRankings("xp")
if err != nil || len(entries) == 0 {
return ""
}
for i, e := range entries {
if e.UserID == userID {
return fmt.Sprintf("League rank: #%d of %d", i+1, len(entries))
@@ -67,61 +84,14 @@ func (h *Handler) buildLeagueRankText(userID int64) string {
return ""
}
// handleLeagueMsg handles the /league command.
func (h *Handler) handleLeagueMsg(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
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.LeagueOptIn {
h.sendText(ctx, msg.Chat.ID, "League participation is disabled.\nEnable it in Settings or use /leaguetoggle.")
return
}
text := h.buildLeagueText(LeagueSortXP)
kb := h.buildLeagueKeyboard("xp")
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// handleLeagueCallback handles league inline view.
func (h *Handler) handleLeagueCallback(ctx context.Context, chatID int64, msgID int, callbackID, orderBy string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if !st.LeagueOptIn {
kb := backKeyboard()
h.editText(ctx, chatID, msgID, "League participation is disabled.\nEnable it in Settings to view rankings.", &kb)
return
}
text := h.buildLeagueText(LeagueSortOption(orderBy))
kb := h.buildLeagueKeyboard(orderBy)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildLeagueKeyboard creates the league navigation keyboard (2x2 sort grid).
func (h *Handler) buildLeagueKeyboard(currentOrder string) models.InlineKeyboardMarkup {
type sortBtn struct {
label string
data string
}
type sortBtn struct{ label, data string }
orders := []sortBtn{
{"XP", "league_xp"},
{"Level", "league_level"},
{"Streak", "league_streak"},
{"Hours", "league_hours"},
}
rows := [][]models.InlineKeyboardButton{}
for i := 0; i < len(orders); i += 2 {
row := []models.InlineKeyboardButton{}

91
internal/handler/note.go Normal file
View File

@@ -0,0 +1,91 @@
package handler
import (
"context"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// handleNote handles /note [YYYY-MM-DD] HH:MM <text> command.
func (h *Handler) handleNote(ctx context.Context, msg *models.Message, user *domain.User) {
loc := h.loadLocation(user.Timezone)
parts := strings.Fields(msg.Text)
if len(parts) < 3 {
h.promptForNote(ctx, msg, user)
return
}
var dateStr string
var timeStr string
var noteParts []string
if len(parts[1]) == 10 && parts[1][4] == '-' && parts[1][7] == '-' {
dateStr = parts[1]
timeStr = parts[2]
noteParts = parts[3:]
} else {
dateStr = time.Now().In(loc).Format(domain.DateLayout)
timeStr = parts[1]
noteParts = parts[2:]
}
if len(noteParts) == 0 {
h.promptForNote(ctx, msg, user)
return
}
events, err := h.Repo.EventsForDayByDate(user.ID, dateStr)
if err != nil || len(events) == 0 {
h.sendText(ctx, msg.Chat.ID, "No events found for that day")
return
}
var targetEvent *domain.Event
for i := range events {
t := time.Unix(events[i].OccurredAt, 0).In(loc).Format(domain.TimeLayout)
if t == timeStr {
targetEvent = &events[i]
break
}
}
if targetEvent == nil {
h.sendText(ctx, msg.Chat.ID, "No event found at that time")
return
}
note := domain.SanitizeNote(strings.Join(noteParts, " "))
if err := h.Repo.SetEventNote(targetEvent.ID, note); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving note")
return
}
h.sendText(ctx, msg.Chat.ID, "Note saved")
}
func (h *Handler) promptForNote(ctx context.Context, msg *models.Message, user *domain.User) {
h.sendText(ctx, msg.Chat.ID, "Usage: /note [YYYY-MM-DD] HH:MM <text>\nUse the History menu to add notes visually.")
}
// handleEdit handles /edit YYYY-MM-DD command.
func (h *Handler) handleEdit(ctx context.Context, msg *models.Message, user *domain.User) {
date := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/edit"))
if date == "" {
h.sendText(ctx, msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)")
return
}
if len(date) != 10 || date[4] != '-' || date[7] != '-' {
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)")
return
}
_, m, d := domain.ParseGregorianDateKey(date)
if m < 1 || m > 12 || d < 1 || d > 31 {
h.sendText(ctx, msg.Chat.ID, "Invalid date.")
return
}
loc := h.loadLocation(user.Timezone)
date = domain.UserDateToGregorian(date, user.Calendar)
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
}

169
internal/handler/report.go Normal file
View File

@@ -0,0 +1,169 @@
package handler
import (
"context"
"fmt"
"log/slog"
"time"
tgbot "github.com/go-telegram/bot"
"worktimeBot/internal/domain"
)
func (h *Handler) buildStatusText(user *domain.User) string {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
today := now.Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return "Welcome. Choose an action:"
}
text := fmt.Sprintf("Today: %s", domain.FormatDateForCalendar(day.Date, user.Calendar))
if day.IsDayOff {
text += "\nDay Off"
}
wt, _ := h.Repo.GetWorkType(day.CurrentWorkTypeID)
if wt != nil {
text += fmt.Sprintf("\nWork type: %s", wt.Name)
}
last, err := h.Repo.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
t := time.Unix(last.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
text += fmt.Sprintf("\nStatus: working since %s", t)
} else {
text += "\nStatus: not working"
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err == nil && len(events) > 0 {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, now.Unix())
text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds))
}
st, _ := h.Repo.GetOrCreateSettings(user.ID)
if st != nil && st.HourlyRate > 0 {
if events, err := h.Repo.EventsForDayByDayID(day.ID); err == nil && len(events) > 0 {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, now.Unix())
sal := h.buildSalaryStatus(user, totals.TotalSeconds)
if sal != "" {
text += sal
}
}
}
if st != nil && st.RPGEnabled {
stats, _ := h.Repo.GetOrCreateRPGStats(user.ID)
if stats != nil {
text += fmt.Sprintf("\nLevel: %d | XP: %d", stats.Level, stats.XP)
rankText := h.buildLeagueRankText(user.ID)
if rankText != "" {
text += "\n " + rankText
}
}
}
text += "\n\nChoose an action:"
return text
}
func (h *Handler) buildDailyReport(user *domain.User, day *domain.Day, events []domain.Event) string {
loc := h.loadLocation(user.Timezone)
if len(events) == 0 {
if day.IsDayOff {
return fmt.Sprintf("%s - Day Off", domain.FormatDateForCalendar(day.Date, user.Calendar))
}
return fmt.Sprintf("%s - No events recorded.", domain.FormatDateForCalendar(day.Date, user.Calendar))
}
y, m, d := domain.ParseGregorianDateKey(day.Date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
now := time.Now().In(loc)
todayStr := now.Format(domain.DateLayout)
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
if day.Date == todayStr {
dayEnd = now.Unix()
}
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
lines := fmt.Sprintf("Report for %s", domain.FormatDateForCalendar(day.Date, user.Calendar))
if day.IsDayOff {
lines += "\nDay Off: Yes"
}
lines += "\n\n"
for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
label := "IN"
if e.EventType == "out" {
label = "OUT"
}
suffix := ""
if e.WorkTypeID != nil {
if wtObj, err := h.Repo.GetWorkType(*e.WorkTypeID); err == nil {
suffix = " [" + wtObj.Name + "]"
}
}
if e.Note != "" {
suffix += " (" + e.Note + ")"
}
lines += fmt.Sprintf("%s %s%s\n", label, t, suffix)
}
lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n",
formatDuration(totals.TotalSeconds), formatDuration(totals.BreakSeconds))
st, _ := h.Repo.GetOrCreateSettings(user.ID)
if st != nil && st.HourlyRate > 0 {
est := domain.ComputeDailySalary(totals.TotalSeconds, st.HourlyRate)
s := domain.SalaryEstimate{WorkSeconds: totals.TotalSeconds, GrossEarning: est}
lines += fmt.Sprintf(" Salary: %s\n", s.FormatSalaryShort(st.Currency))
}
return lines
}
func (h *Handler) buildSalaryStatus(user *domain.User, workSeconds int64) string {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil || st.HourlyRate <= 0 {
return ""
}
est := domain.ComputeDailySalary(workSeconds, st.HourlyRate)
s := domain.SalaryEstimate{WorkSeconds: workSeconds, GrossEarning: est, HourlyRate: st.HourlyRate}
return fmt.Sprintf("\nSalary: %s (%.2f/hr)", s.FormatSalaryShort(st.Currency), st.HourlyRate)
}
func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, cbID string) {
defer h.answerCb(ctx, cbID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
h.editText(ctx, chatID, msgID, "Error loading profile", nil)
return
}
kb := mainKeyboard()
h.editText(ctx, chatID, msgID, h.buildStatusText(user), &kb)
}
// SendDailyReport sends a daily report to a specific user (called by scheduler).
func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int64) {
user, err := h.Repo.GetOrCreateUser(chatID)
if err != nil {
slog.Error("SendDailyReport: get user", "chat_id", chatID, "error", err)
return
}
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
slog.Error("SendDailyReport: get day", "chat_id", chatID, "error", err)
return
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil {
slog.Error("SendDailyReport: events", "chat_id", chatID, "error", err)
return
}
text := h.buildDailyReport(user, day, events)
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil {
slog.Warn("SendDailyReport: send", "chat_id", chatID, "error", err)
}
}

336
internal/handler/rpg.go Normal file
View File

@@ -0,0 +1,336 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Commands --
func (h *Handler) handleRPG(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.RPGEnabled {
h.sendText(ctx, msg.Chat.ID, "RPG system is disabled. Enable it in Settings or use /rpgtoggle.")
return
}
text := h.buildRPGStatus(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) handleAchievements(ctx context.Context, msg *models.Message, user *domain.User) {
text := h.buildAchievementsList(user)
h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard())
}
// -- Callbacks --
func (h *Handler) rpgCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildRPGStatus(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) achievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildAchievementsList(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) rpgAchievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildAchievementsList(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to RPG", CallbackData: "rpg_back"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) rpgBackCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildRPGStatus(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- RPG business logic --
func (h *Handler) buildRPGStatus(user *domain.User) string {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil || !st.RPGEnabled {
return "RPG system is disabled."
}
stats, err := h.Repo.GetOrCreateRPGStats(user.ID)
if err != nil {
return "RPG stats unavailable"
}
nextLevelXP := domain.XpForLevel(stats.Level + 1)
currentLevelXP := domain.XpForLevel(stats.Level)
xpIntoLevel := stats.XP - currentLevelXP
xpNeeded := nextLevelXP - currentLevelXP
var b strings.Builder
b.WriteString(fmt.Sprintf("Level: %d\n", stats.Level))
b.WriteString(fmt.Sprintf("XP: %d / %d (%d%%)\n", stats.XP, nextLevelXP, xpIntoLevel*100/xpNeeded))
b.WriteString(fmt.Sprintf("Streak: %d days (best: %d)\n", stats.CurrentStreak, stats.LongestStreak))
hoursTotal := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour
b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal))
achs, _ := h.Repo.GetUserAchievements(user.ID)
if len(achs) > 0 {
b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs)))
}
return b.String()
}
func (h *Handler) buildAchievementsList(user *domain.User) string {
achs, err := h.Repo.GetUserAchievements(user.ID)
if err != nil {
return "Achievements unavailable"
}
if len(achs) == 0 {
return "No achievements unlocked yet.\nWork consistently to earn them!"
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Achievements (%d):\n\n", len(achs)))
for _, a := range achs {
t := time.Unix(a.UnlockedAt, 0)
b.WriteString(fmt.Sprintf(" %s\n %s - %s\n", a.Name, a.Description, t.Format(domain.DateLayout)))
}
return b.String()
}
func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string {
text := ""
h.updateStreak(stats, user.ID, today)
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
totalHours := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
if leveledUp {
text += "\nLevel up!"
}
for _, a := range unlocked {
text += fmt.Sprintf("\nAchievement: %s", a)
}
return text
}
func (h *Handler) computeAndAwardXP(stats *domain.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, _ error) {
baseXP := domain.XpForWorkSeconds(workSeconds)
bonus := domain.StreakBonus(streak)
totalXP := baseXP + bonus
if totalXP < 0 {
totalXP = 0
}
stats.XP += totalXP
newLevel = domain.LevelForXP(stats.XP)
leveledUp = newLevel > stats.Level
stats.Level = newLevel
stats.TotalWorkSeconds += workSeconds
if err := h.Repo.UpdateRPGStats(stats); err != nil {
return 0, false, 0, err
}
if err := h.Repo.UpsertDailyXP(userID, date, totalXP, workSeconds); err != nil {
slog.Warn("failed to upsert daily xp", "user_id", userID, "error", err)
}
return stats.XP, leveledUp, newLevel, nil
}
func (h *Handler) updateStreak(stats *domain.RPGStats, userID int64, date string) {
t, err := time.Parse(domain.DateLayout, date)
if err != nil {
return
}
yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout)
prevEvents, err := h.Repo.EventsForDayByDate(userID, yesterday)
hadWorkYesterday := err == nil && len(prevEvents) > 0
if hadWorkYesterday {
stats.CurrentStreak++
} else {
stats.CurrentStreak = 1
}
if stats.CurrentStreak > stats.LongestStreak {
stats.LongestStreak = stats.CurrentStreak
}
}
func (h *Handler) recalcDayWorkSeconds(userID int64, date string, loc *time.Location) {
events, err := h.Repo.EventsForDayByDate(userID, date)
if err != nil {
return
}
day, err := h.Repo.GetDay(userID, date)
if err != nil {
return
}
y, m, d := domain.ParseGregorianDateKey(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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if err := h.Repo.SetDailyWorkSeconds(userID, date, totals.TotalSeconds); err != nil {
slog.Warn("failed to set daily work seconds", "user_id", userID, "date", date, "error", err)
}
}
func (h *Handler) recalcAggregates(userID int64, loc *time.Location) {
total, err := h.Repo.SumDailyWorkSeconds(userID)
if err != nil {
slog.Warn("failed to sum daily work seconds", "user_id", userID, "error", err)
return
}
stats, err := h.Repo.GetOrCreateRPGStats(userID)
if err != nil {
slog.Warn("failed to get rpg stats for recalc", "user_id", userID, "error", err)
return
}
stats.TotalWorkSeconds = total
dates, err := h.Repo.GetDatesWithEvents(userID)
today := time.Now().In(loc).Format(domain.DateLayout)
if err == nil {
stats.CurrentStreak, stats.LongestStreak = domain.ComputeStreakFromDates(dates, today)
} else {
stats.CurrentStreak = 0
stats.LongestStreak = 0
}
if err := h.Repo.UpdateRPGStats(stats); err != nil {
slog.Warn("failed to update rpg stats after recalc", "user_id", userID, "error", err)
}
}
// -- Achievements --
func (h *Handler) checkAchievements(userID int64, stats *domain.RPGStats, workSeconds int64, date string, isWeekend bool, totalHours float64) []string {
u := &achievementUnlocker{h: h, userID: userID}
h.checkEarlyAchievements(u, workSeconds)
h.checkEventTimeAchievements(u, userID, date)
h.checkStreakAchievements(u, stats)
h.checkHourAchievements(u, stats, totalHours)
h.checkSessionAchievements(u, workSeconds, isWeekend)
h.checkLevelAchievements(u, stats)
return u.unlocked
}
type achievementUnlocker struct {
h *Handler
userID int64
unlocked []string
}
func (u *achievementUnlocker) unlock(code string) {
a, err := u.h.Repo.GetAchievementByCode(code)
if err != nil {
return
}
ok, err := u.h.Repo.UnlockAchievement(u.userID, a.ID)
if err != nil {
slog.Error("unlock achievement", "code", code, "error", err)
return
}
if ok {
u.unlocked = append(u.unlocked, a.Name)
}
}
func (h *Handler) checkEarlyAchievements(u *achievementUnlocker, workSeconds int64) {
has, _ := h.Repo.HasAchievement(u.userID, "first_clockin")
if !has && workSeconds > 0 {
u.unlock("first_clockin")
}
}
func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) {
events, err := h.Repo.EventsForDayByDate(userID, date)
if err != nil {
return
}
for _, e := range events {
et := time.Unix(e.OccurredAt, 0)
hh, mm, _ := et.Clock()
minutes := hh*60 + mm
if e.EventType == "in" && minutes < 7*60 {
u.unlock("early_bird")
}
if e.EventType == "out" && minutes >= 22*60 {
u.unlock("night_owl")
}
}
}
func (h *Handler) checkStreakAchievements(u *achievementUnlocker, stats *domain.RPGStats) {
if stats.CurrentStreak >= 5 {
u.unlock("iron_streak_5")
}
if stats.CurrentStreak >= 10 {
u.unlock("iron_streak_10")
}
if stats.CurrentStreak >= 30 {
u.unlock("iron_streak_30")
}
}
func (h *Handler) checkHourAchievements(u *achievementUnlocker, stats *domain.RPGStats, hoursWorked float64) {
if hoursWorked >= 100.0 {
u.unlock("hundred_hours")
}
if hoursWorked >= 500.0 {
u.unlock("five_hundred_hours")
}
}
func (h *Handler) checkSessionAchievements(u *achievementUnlocker, workSeconds int64, isWeekend bool) {
if workSeconds > 10*domain.SecondsPerHour {
u.unlock("overtime_hero")
}
if isWeekend && workSeconds > 0 {
u.unlock("weekend_warrior")
}
}
func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *domain.RPGStats) {
if stats.Level >= 5 {
u.unlock("level_5")
}
if stats.Level >= 10 {
u.unlock("level_10")
}
if stats.Level >= 25 {
u.unlock("level_25")
}
if stats.Level >= 50 {
u.unlock("level_50")
}
}

View File

@@ -0,0 +1,95 @@
package handler
import (
"context"
"fmt"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Command --
func (h *Handler) handleSalary(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.buildSalaryEstimate(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard())
}
// -- Callback --
func (h *Handler) salaryCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.buildSalaryEstimate(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Builders --
func (h *Handler) buildSalaryEstimate(user *domain.User) (string, error) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return "", err
}
if st.HourlyRate <= 0 {
return "No hourly rate configured.\nUse /setrate <amount> to set your rate.", nil
}
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
calY, calM := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
}
startStr, endStr := domain.MonthGregorianRange(user.Calendar, calY, calM)
days, err := h.Repo.GetUserDaysInRange(user.ID, startStr, endStr)
if err != nil {
return "", err
}
est := h.computeMonthlySalary(days, user.ID, st.HourlyRate, loc)
est.Currency = st.Currency
est.HourlyRate = st.HourlyRate
title := domain.FormatMonthTitle(user.Calendar, calY, calM)
return fmt.Sprintf("Salary estimate for %s\n%s\nRate: %s%.2f/hr\nDays worked: %d\nHours: %.1f",
title, est.FormatSalary(st.Currency), domain.CurrencySymbol(st.Currency), st.HourlyRate,
est.WorkDays, est.WorkHours), nil
}
func (h *Handler) computeMonthlySalary(days []domain.Day, userID int64, hourlyRate float64, loc *time.Location) domain.SalaryEstimate {
var totalWork int64
workDayCount := 0
for _, day := range days {
if day.IsDayOff {
continue
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil || len(events) == 0 {
continue
}
y, m, d := domain.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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if totals.TotalSeconds > 0 {
totalWork += totals.TotalSeconds
workDayCount++
}
}
hours := float64(totalWork) / domain.SecondsPerHour
gross := domain.ComputeDailySalary(totalWork, hourlyRate)
return domain.SalaryEstimate{
WorkSeconds: totalWork, WorkHours: hours,
GrossEarning: gross, WorkDays: workDayCount,
}
}

View File

@@ -0,0 +1,720 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- /start and /help --
func (h *Handler) handleStart(ctx context.Context, msg *models.Message, user *domain.User) {
slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID)
kb := mainKeyboard()
h.sendWithKB(ctx, msg.Chat.ID, h.buildStatusText(user), kb)
}
func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
text := `Commands:
/start - Show main menu
/clockin - Clock in
/clockout - Clock out
/worktype - Select work type
/dayoff - Toggle day off
/report - Show today report
/export [YYYY-MM] - Export monthly report
/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
/reporttoggle - Enable/disable auto report
/setreporttime HH:MM - Set daily report time
/setaccent - Select accent color
/settimezone <name> - Set timezone (e.g. Asia/Tehran)
/setbreak <minutes> - Set break threshold in minutes
/setusername <name> - Set your display name
/rpg - Show RPG stats and XP
/achievements - View unlocked achievements
/salary - Show salary estimate
/burnout - View burnout assessment
/league - View WorkTime League rankings
/setrate <amount> - Set hourly rate (e.g. 25.50)
/setcurrency <symbol> <code> - Set currency
/rpgtoggle - Enable/disable RPG system
/leaguetoggle - Enable/disable league participation`
h.sendText(ctx, msg.Chat.ID, text)
}
// -- Settings menu callbacks --
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) buildSettingsKeyboard(user *domain.User, st *domain.UserSettings, breakThreshold int64) (string, models.InlineKeyboardMarkup) {
reportStatus := "off"
if st.ReportEnabled {
reportStatus = "on"
}
rpgStatus := "off"
if st.RPGEnabled {
rpgStatus = "on"
}
leagueStatus := "off"
if st.RPGEnabled && st.LeagueOptIn {
leagueStatus = "on"
}
rows := [][]models.InlineKeyboardButton{
{{Text: fmt.Sprintf("Username: %s", user.Username), CallbackData: "username"}},
{
{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"},
{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"},
},
{
{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"},
{Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"},
},
{
{Text: fmt.Sprintf("Break: %dm", breakThreshold/60), CallbackData: "breakthreshold"},
{Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"},
},
{
{Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"},
{Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"},
},
{
{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"},
{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"},
},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
}
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Toggle callbacks (binary select pickers) --
func (h *Handler) rpgToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, kb := h.buildToggleKeyboard(user.ID, "rpg", "RPG System")
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) leagueToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if !st.RPGEnabled {
h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", nil)
return
}
text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League")
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) reportToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, kb := h.buildToggleKeyboard(user.ID, "report", "Daily Report")
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) buildToggleKeyboard(userID int64, setting, label string) (string, models.InlineKeyboardMarkup) {
st, err := h.Repo.GetOrCreateSettings(userID)
current := false
if err == nil {
switch setting {
case "rpg":
current = st.RPGEnabled
case "league":
current = st.LeagueOptIn
case "report":
current = st.ReportEnabled
}
}
options := []struct {
label string
data string
value bool
}{
{"Enabled", setting + "_enable", true},
{"Disabled", setting + "_disable", false},
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range options {
l := opt.label
if opt.value == current {
l = "> " + l
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: l, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return label + ":", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) setRPGCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.RPGEnabled = enable
if enable {
h.Repo.GetOrCreateRPGStats(user.ID)
h.tryUnlockAchievement(user.ID, "rpg_pioneer")
} else {
st.LeagueOptIn = false
}
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) setLeagueCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if enable && !st.RPGEnabled {
h.answerCbWithText(ctx, cbID, "Enable RPG first")
return
}
st.LeagueOptIn = enable
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) setReportToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.ReportEnabled = enable
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Prompt callbacks (settings items that need text input) --
func (h *Handler) usernameCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingUsername,
fmt.Sprintf("Current username: %s\n\nSend me the new username:", user.Username), nil)
}
func (h *Handler) rateCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingRate,
"Send me the hourly rate (e.g. 25.50):", nil)
}
func (h *Handler) currencyCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingCurrency,
"Send me the currency (symbol and code, e.g. $ USD):\nPresets: $ USD, T Toman, IRR Rial, EUR, GBP", nil)
}
func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingTimezone,
fmt.Sprintf("Current timezone: %s\n\nSend me the IANA timezone (e.g. Asia/Tehran, Europe/London):", user.Timezone), nil)
}
func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingBreak,
"Send me the break threshold in minutes (0-480, e.g. 15):", nil)
}
func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, _ := h.Repo.GetOrCreateSettings(user.ID)
current := "08:00"
if st != nil {
current = st.ReportTime
}
h.promptForInput(ctx, chatID, msgID, user.ID, PendingReportTime,
fmt.Sprintf("Current report time: %s\n\nSend me the report time in HH:MM (24-hour):", current), nil)
}
func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, kb := h.buildCalendarTypeKeyboard(user)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildAccentKeyboard(st)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Accent picker --
func (h *Handler) buildAccentKeyboard(st *domain.UserSettings) (string, models.InlineKeyboardMarkup) {
type accentOption struct{ name, color string }
accents := []accentOption{
{"ocean", "Blue"},
{"beach", "Warm"},
{"rose", "Pink"},
{"catppuccin", "Purple"},
}
rows := [][]models.InlineKeyboardButton{}
for _, a := range accents {
label := fmt.Sprintf("%s (%s)", a.name, a.color)
if a.name == st.ExportAccent {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "accent_" + a.name},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select accent color:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, name string) {
defer h.answerCb(ctx, cbID)
valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true}
if !valid[name] {
return
}
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.ExportAccent = name
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Calendar type picker --
func (h *Handler) buildCalendarTypeKeyboard(user *domain.User) (string, models.InlineKeyboardMarkup) {
cals := []string{"gregorian", "jalali", "hijri"}
calLabels := map[string]string{
"gregorian": "Gregorian",
"jalali": "Jalali",
"hijri": "Hijri",
}
rows := [][]models.InlineKeyboardButton{}
for _, c := range cals {
label := calLabels[c]
if c == user.Calendar {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "caltype_" + c},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select calendar type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, name string) {
defer h.answerCb(ctx, cbID)
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
if !valid[name] {
return
}
user.Calendar = name
if err := h.Repo.UpdateUser(user); err != nil {
return
}
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Break threshold select (preset picker) --
func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, valStr string) {
defer h.answerCb(ctx, cbID)
mins, err := strconv.Atoi(valStr)
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
return
}
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return
}
day.MinBreakThreshold = int64(mins) * 60
h.Repo.UpdateDay(day)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Currency preset picker --
func (h *Handler) selectCurrency(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, currency string) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.Currency = currency
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Command implementations for settings (prompt-and-wait OR direct args) --
func (h *Handler) handleSetRate(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setrate"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingRate,
"Send me the hourly rate (e.g. 25.50):", nil)
return
}
var rate float64
if _, err := fmt.Sscanf(args, "%f", &rate); err != nil || rate < 0 || rate > domain.MaxHourlyRate {
h.sendText(ctx, msg.Chat.ID, "Invalid rate. Use a positive number (e.g. /setrate 25.50)")
return
}
h.saveRate(ctx, msg.Chat.ID, user, rate)
}
func (h *Handler) handleSetCurrency(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingCurrency,
"Send me the currency (symbol and code, e.g. $ USD):", nil)
return
}
currency, errMsg := domain.ParseCurrencyInput(args)
if errMsg != "" {
h.sendText(ctx, msg.Chat.ID, errMsg)
return
}
h.saveCurrency(ctx, msg.Chat.ID, user, currency)
}
func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/settimezone"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingTimezone,
fmt.Sprintf("Current timezone: %s\n\nSend me the IANA timezone:", user.Timezone), nil)
return
}
if _, err := time.LoadLocation(args); err != nil {
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran", args))
return
}
h.saveTimezone(ctx, msg.Chat.ID, user, args)
}
func (h *Handler) handleSetBreak(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setbreak"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingBreak,
"Send me the break threshold in minutes (0-480):", nil)
return
}
mins, err := strconv.Atoi(args)
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
h.sendText(ctx, msg.Chat.ID, "Invalid value. Must be between 0 and 480 minutes.")
return
}
h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins)
}
func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message, user *domain.User) {
name := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setusername"))
if name == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingUsername,
fmt.Sprintf("Current username: %s\n\nSend me the new username:", user.Username), nil)
return
}
h.saveUsername(ctx, msg.Chat.ID, user, name)
}
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setreporttime"))
if args == "" {
st, _ := h.Repo.GetOrCreateSettings(user.ID)
current := "08:00"
if st != nil {
current = st.ReportTime
}
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingReportTime,
fmt.Sprintf("Current report time: %s\n\nSend me the report time in HH:MM:", current), nil)
return
}
if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, args); err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
}
}
func (h *Handler) handleSetAccent(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
text, kb := h.buildAccentKeyboard(st)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) handleRPGSettings(ctx context.Context, msg *models.Message, user *domain.User) {
text, kb := h.buildToggleKeyboard(user.ID, "rpg", "RPG System")
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) handleLeagueToggle(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.RPGEnabled {
h.sendText(ctx, msg.Chat.ID, "League requires the RPG system. Enable RPG first with /rpgtoggle.")
return
}
text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League")
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// -- Pending input processors --
func (h *Handler) processRateInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
var rate float64
if _, err := fmt.Sscanf(text, "%f", &rate); err != nil || rate < 0 || rate > domain.MaxHourlyRate {
h.editText(ctx, msg.Chat.ID, pi.MsgID, "Invalid rate. Use a positive number.", &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "setrate"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveRate(ctx, msg.Chat.ID, user, rate)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID,
fmt.Sprintf("Hourly rate set to %s%.2f", domain.CurrencySymbol(""), rate), &backKB)
}
func (h *Handler) processCurrencyInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
currency, errMsg := domain.ParseCurrencyInput(text)
if errMsg != "" {
h.editText(ctx, msg.Chat.ID, pi.MsgID, errMsg, &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "currency"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveCurrency(ctx, msg.Chat.ID, user, currency)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Currency set to: %s", currency), &backKB)
}
func (h *Handler) processTimezoneInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
if _, err := time.LoadLocation(text); err != nil {
h.editText(ctx, msg.Chat.ID, pi.MsgID,
fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran", text), &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "timezone"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveTimezone(ctx, msg.Chat.ID, user, text)
loc := h.loadLocation(text)
now := time.Now().In(loc)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID,
fmt.Sprintf("Timezone set to %s (current time: %s)", text, now.Format(domain.TimeLayout)), &backKB)
}
func (h *Handler) processBreakInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
mins, err := strconv.Atoi(text)
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
h.editText(ctx, msg.Chat.ID, pi.MsgID,
"Invalid value. Must be between 0 and 480 minutes.", &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "breakthreshold"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins)
st, _ := h.Repo.GetOrCreateSettings(user.ID)
bt := h.getTodayBreakThreshold(user)
text2, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, msg.Chat.ID, pi.MsgID, text2, &kb)
}
func (h *Handler) processUsernameInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
h.saveUsername(ctx, msg.Chat.ID, user, text)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Username set to: %s", text), &backKB)
}
func (h *Handler) processReportTimeInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, text); err != nil {
h.editText(ctx, msg.Chat.ID, pi.MsgID, err.Error(), &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "reporttime"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Report time set to %s", text), &backKB)
}
func (h *Handler) processNoteInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
eventIDStr, ok := pi.Data["event_id"]
if !ok {
return
}
var eventID int64
if _, err := fmt.Sscanf(eventIDStr, "%d", &eventID); err != nil {
return
}
note := domain.SanitizeNote(text)
if err := h.Repo.SetEventNote(eventID, note); err != nil {
slog.Error("failed to save event note", "event_id", eventID, "error", err)
return
}
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
loc := h.loadLocation(user.Timezone)
date := time.Unix(event.OccurredAt, 0).In(loc).Format(domain.DateLayout)
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
}
// -- Shared save helpers --
func (h *Handler) saveRate(ctx context.Context, chatID int64, user *domain.User, rate float64) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, chatID, "Error saving rate")
return
}
wasZero := st.HourlyRate == 0
st.HourlyRate = float64(int(rate*domain.SalaryRoundFactor)) / 100.0
if err := h.Repo.UpdateSettings(st); err != nil {
h.sendText(ctx, chatID, "Error saving rate")
return
}
if wasZero {
h.tryUnlockAchievement(user.ID, "salary_setter")
}
}
func (h *Handler) saveCurrency(_ context.Context, _ int64, user *domain.User, currency string) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.Currency = currency
h.Repo.UpdateSettings(st)
}
func (h *Handler) saveTimezone(_ context.Context, _ int64, user *domain.User, tz string) {
user.Timezone = tz
h.Repo.UpdateUser(user)
}
func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.User, mins int) {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return
}
day.MinBreakThreshold = int64(mins) * 60
h.Repo.UpdateDay(day)
}
func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, name string) {
name = domain.SanitizeDisplayName(name)
if name == "" {
return
}
user.Username = name
h.Repo.UpdateUser(user)
}
func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *domain.User, timeStr string) error {
if len(timeStr) != 5 || timeStr[2] != ':' {
return fmt.Errorf("invalid format. Use HH:MM (24-hour)")
}
hours, err1 := strconv.Atoi(timeStr[:2])
mins, err2 := strconv.Atoi(timeStr[3:])
if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 {
return fmt.Errorf("invalid time. Use HH:MM (24-hour)")
}
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return fmt.Errorf("error saving report time")
}
st.ReportTime = timeStr
return h.Repo.UpdateSettings(st)
}

View File

@@ -1,4 +1,4 @@
package bot
package handler
import (
"context"
@@ -8,21 +8,15 @@ import (
"github.com/go-telegram/bot/models"
"worktimeBot/internal/db"
"worktimeBot/internal/domain"
)
// 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)
func (h *Handler) handleSummary(ctx context.Context, msg *models.Message, user *domain.User) {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
args := strings.Fields(msg.Text)
if len(args) > 0 && args[0][0] == '/' {
if len(args) >= 1 && args[0][0] == '/' {
args = args[1:]
}
@@ -36,25 +30,24 @@ func (h *Handler) handleSummaryMsg(ctx context.Context, msg *models.Message) {
calY, calM = now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = gregorianToJalali(calY, calM, now.Day())
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = gregorianToHijri(calY, calM, now.Day())
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
}
}
startStr, endStr := monthGregorianRange(user.Calendar, calY, calM)
startStr, endStr := domain.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)
func (h *Handler) buildMonthlySummary(user *domain.User, calY, calM int, startStr, endStr string, loc *time.Location) string {
days, err := h.Repo.GetUserDaysInRange(user.ID, startStr, endStr)
if err != nil {
return "Error loading days"
}
title := formatMonthTitle(user.Calendar, calY, calM)
title := domain.FormatMonthTitle(user.Calendar, calY, calM)
totalWork := int64(0)
totalBreak := int64(0)
@@ -63,13 +56,13 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
dayDetails := []string{}
for _, day := range days {
dayLabel := formatDateForCalendar(day.Date, user.Calendar)
dayLabel := domain.FormatDateForCalendar(day.Date, user.Calendar)
if day.IsDayOff {
dayOffCount++
}
events, err := h.DB.EventsForDayByDayID(day.ID)
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil || len(events) == 0 {
if day.IsDayOff {
dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel))
@@ -77,10 +70,10 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
continue
}
y, m, d := parseGregorianDateKey(day.Date)
y, m, d := domain.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)
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if totals.TotalSeconds == 0 {
if day.IsDayOff {
dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel))
@@ -109,13 +102,13 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak)))
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
st, _ := h.Repo.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
est := computeMonthlySalary(days, h.DB, user.ID, st.HourlyRate, loc)
est := h.computeMonthlySalary(days, user.ID, st.HourlyRate, loc)
b.WriteString(fmt.Sprintf("\nSalary: %s (%.1f hours, %d days)",
est.formatSalary(st.Currency), est.WorkHours, est.WorkDays))
est.FormatSalaryShort(st.Currency), est.WorkHours, est.WorkDays))
} else {
b.WriteString("\nSalary: No rate configured — use /setrate <amount>")
b.WriteString("\nSalary: No rate configured. Use /setrate <amount>")
}
b.WriteString("\n\nDetails:\n")

View File

@@ -0,0 +1,75 @@
package handler
import (
"context"
"fmt"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
func (h *Handler) handleWorkType(ctx context.Context, msg *models.Message, user *domain.User) {
day, err := h.getTodayDay(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading work types")
return
}
text, kb := h.buildWorkTypeKeyboard(user, day)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) workTypeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
day, err := h.getTodayDay(user)
if err != nil {
kb := backKeyboard()
h.editText(ctx, chatID, msgID, "Error loading work types", &kb)
return
}
text, kb := h.buildWorkTypeKeyboard(user, day)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) buildWorkTypeKeyboard(user *domain.User, day *domain.Day) (string, models.InlineKeyboardMarkup) {
wts, err := h.Repo.GetWorkTypes()
if err != nil || len(wts) == 0 {
return "No work types available.", backKeyboard()
}
rows := [][]models.InlineKeyboardButton{}
for _, wt := range wts {
label := wt.Name
if wt.ID == day.CurrentWorkTypeID {
label = "> " + wt.Name
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: fmt.Sprintf("worktype_%d", wt.ID)},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
return "Select work type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) selectWorkType(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, idStr string) {
defer h.answerCb(ctx, cbID)
day, err := h.getTodayDay(user)
if err != nil {
return
}
var wtID int64
if _, err := fmt.Sscanf(idStr, "%d", &wtID); err != nil {
return
}
wt, err := h.Repo.GetWorkType(wtID)
if err != nil {
return
}
day.CurrentWorkTypeID = wtID
if err := h.Repo.UpdateDay(day); err != nil {
return
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, fmt.Sprintf("Work type set to: %s", wt.Name), &kb)
}

View File

@@ -0,0 +1,59 @@
package repo
import "worktimeBot/internal/domain"
type Repository interface {
// Users
GetOrCreateUser(chatID int64) (*domain.User, error)
UpdateUser(user *domain.User) error
GetAllUsers() ([]domain.User, error)
// Settings
GetOrCreateSettings(userID int64) (*domain.UserSettings, error)
UpdateSettings(st *domain.UserSettings) error
MarkReportSent(userID int64, date string) error
// Days
GetOrCreateDay(userID int64, date string) (*domain.Day, error)
GetDay(userID int64, date string) (*domain.Day, error)
UpdateDay(day *domain.Day) error
GetUserDaysInRange(userID int64, start, end string) ([]domain.Day, error)
SetDayOff(userID int64, date, reason string) error
RemoveDayOff(userID int64, date string) error
// Events
CreateEvent(userID, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error
GetLastEvent(userID int64) (*domain.Event, error)
EventsForDayByDate(userID int64, date string) ([]domain.Event, error)
EventsForDayByDayID(dayID int64) ([]domain.Event, error)
GetEventByID(eventID, userID int64) (*domain.Event, error)
UpdateEventTime(eventID int64, occurredAt int64) error
UpdateEventWorkType(eventID, workTypeID int64) error
SetEventNote(eventID int64, note string) error
DeleteEvent(eventID int64) error
GetDistinctEventDates(userID int64, start, end string) ([]string, error)
GetDatesWithEvents(userID int64) ([]string, error)
// Work Types
GetWorkTypes() ([]domain.WorkType, error)
GetWorkType(id int64) (*domain.WorkType, error)
// RPG
GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error)
UpdateRPGStats(stats *domain.RPGStats) error
UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error
SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error
SumDailyWorkSeconds(userID int64) (int64, error)
// Achievements
GetAchievementByCode(code string) (*domain.Achievement, error)
UnlockAchievement(userID, achievementID int64) (bool, error)
HasAchievement(userID int64, code string) (bool, error)
GetUserAchievements(userID int64) ([]domain.UserAchievement, error)
// League
GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error)
// Lifecycle
Close() error
}

221
internal/repo/migrations.go Normal file
View File

@@ -0,0 +1,221 @@
package repo
import (
"database/sql"
"embed"
"log/slog"
"time"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// runMigrations handles all database migrations using goose.
func runMigrations(db *sql.DB) error {
needsOldMigrate := hasTable(db, "events_old") || (hasTable(db, "events") && !hasCol(db, "events", "user_id"))
if needsOldMigrate {
slog.Info("migrating from old schema to new schema")
for _, t := range []string{"events", "days_off", "settings"} {
if hasTable(db, t) {
if _, err := db.Exec("ALTER TABLE " + t + " RENAME TO " + t + "_old"); err != nil {
return err
}
}
}
}
goose.SetBaseFS(migrationFS)
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
if err := goose.Up(db, "migrations"); err != nil {
return err
}
if needsOldMigrate {
if err := migrateFromOldSchemaData(db); err != nil {
return err
}
}
return nil
}
func hasTable(db *sql.DB, name string) bool {
var exists int
if err := db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&exists); err != nil {
return false
}
return exists > 0
}
func hasCol(db *sql.DB, table, column string) bool {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false
}
if name == column {
return true
}
}
return false
}
func migrateFromOldSchemaData(db *sql.DB) error {
migrateUsers(db)
migrateDaysOff(db)
migrateEvents(db)
migrateSettings(db)
for _, t := range []string{"days_off_old", "settings_old", "events_old"} {
if hasTable(db, t) {
if _, err := db.Exec("DROP TABLE IF EXISTS " + t); err != nil {
slog.Warn("failed to drop old table", "table", t, "error", err)
}
}
}
slog.Info("migration complete")
return nil
}
func migrateUsers(db *sql.DB) {
if !hasTable(db, "events_old") {
return
}
rows, err := db.Query("SELECT DISTINCT chat_id FROM events_old")
if err != nil {
slog.Warn("migration: failed to query events_old for users", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
if err := rows.Scan(&chatID); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
if _, err := db.Exec(
"INSERT OR IGNORE INTO users (chat_id, timezone) VALUES (?, 'Asia/Tehran')",
chatID,
); err != nil {
slog.Warn("migration: failed to insert user", "chat_id", chatID, "error", err)
}
}
}
func migrateDaysOff(db *sql.DB) {
if !hasTable(db, "days_off_old") {
return
}
rows, err := db.Query("SELECT chat_id, date, reason FROM days_off_old")
if err != nil {
slog.Warn("migration: failed to query days_off_old", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
var date, reason string
if err := rows.Scan(&chatID, &date, &reason); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
var userID int64
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
continue
}
if _, err := db.Exec(
"INSERT INTO days (user_id, date, is_day_off, day_off_reason) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, date) DO UPDATE SET is_day_off=1, day_off_reason=excluded.day_off_reason",
userID, date, reason,
); err != nil {
slog.Warn("migration: failed to insert day_off", "user_id", userID, "date", date, "error", err)
}
}
}
func migrateEvents(db *sql.DB) {
if !hasTable(db, "events_old") {
return
}
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 {
slog.Warn("migration: failed to query events_old", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
var eventType, note string
var occurredAt, createdAt int64
if err := rows.Scan(&chatID, &eventType, &occurredAt, &note, &createdAt); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
var userID int64
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
continue
}
date := time.Unix(occurredAt, 0).UTC().Format("2006-01-02")
if _, err := db.Exec("INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date); err != nil {
slog.Warn("migration: failed to insert day", "user_id", userID, "date", date, "error", err)
continue
}
var dayID int64
if err := db.QueryRow("SELECT id FROM days WHERE user_id=? AND date=?", userID, date).Scan(&dayID); err != nil {
slog.Warn("migration: day not found", "user_id", userID, "date", date, "error", err)
continue
}
if _, err := db.Exec(
"INSERT INTO events (user_id, day_id, event_type, occurred_at, note, created_at) VALUES (?, ?, ?, ?, ?, ?)",
userID, dayID, eventType, occurredAt, note, createdAt,
); err != nil {
slog.Warn("migration: failed to insert event", "user_id", userID, "error", err)
}
}
}
func migrateSettings(db *sql.DB) {
if !hasTable(db, "settings_old") {
return
}
rows, err := db.Query("SELECT chat_id, key, value FROM settings_old")
if err != nil {
slog.Warn("migration: failed to query settings_old", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
var key, value string
if err := rows.Scan(&chatID, &key, &value); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
if key == "remote_flag" {
var userID int64
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
continue
}
wt := int64(1)
if value == "remote" {
wt = 2
}
db.Exec("UPDATE days SET current_work_type_id=? WHERE user_id=? AND date=?", wt, userID, time.Now().UTC().Format("2006-01-02"))
}
}
}

607
internal/repo/store.go Normal file
View File

@@ -0,0 +1,607 @@
package repo
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
_ "modernc.org/sqlite"
"worktimeBot/internal/domain"
)
const driverName = "sqlite"
type Store struct {
db *sql.DB
}
func NewStore(path string) (*Store, error) {
db, err := sql.Open(driverName, path)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
slog.Warn("failed to set WAL mode", "error", err)
}
if err := runMigrations(db); err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
var usernames = []string{
"Lazy Dev", "Solo Leveler", "Local = Prod", "Database Rat",
"Refactoring Perfectionist", "PHP 5 Cultist", "Java Script Slave",
"AI Slop", "OOP Cultist", "Functional Purist",
"Merge Conflict Survivor", "Stack Overflow Diplomat",
"NULL Pointer", "Memory Leak", "Segmentation Fault",
"Infinite Looper", "TypeScript Wizard", "CSS Box Model Gambler",
"Docker Compose Gambler", "YAML Engineer",
"Production Pusher", "Test Skipper", "Code Review Dodger",
"Commit --force User", "Git Merge Master",
"Dependency Hell Dweller", "Deprecation Warning",
"Legacy Code Whisperer", "Agile Scrum Lord", "Stand Up Sitter",
"Sprint Burnout", "Ticket Creator Supreme",
"Technical Debt Collector", "Documentation? Never Heard",
"Stack Trace Reader", "Binary Search Debugger", "Rubber Duck",
"Hotfix Hero", "Feature Flag Addict",
}
func randomUsername() string {
return usernames[time.Now().UnixNano()%int64(len(usernames))]
}
func (s *Store) GetOrCreateUser(chatID int64) (*domain.User, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)",
chatID, randomUsername(),
); err != nil {
return nil, err
}
return s.GetUserByChatID(chatID)
}
func (s *Store) GetUserByChatID(chatID int64) (*domain.User, error) {
row := s.db.QueryRow(`
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
FROM users WHERE chat_id = ?
`, chatID)
var u domain.User
if err := row.Scan(
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) UpdateUser(user *domain.User) error {
_, err := s.db.Exec(
"UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?",
user.Username, user.Timezone, user.IsActive, user.Calendar, user.ID,
)
return err
}
func (s *Store) GetAllUsers() ([]domain.User, error) {
rows, err := s.db.Query(`
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
FROM users
`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []domain.User
for rows.Next() {
var u domain.User
if err := rows.Scan(
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
// Settings
func (s *Store) GetOrCreateSettings(userID int64) (*domain.UserSettings, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)",
userID,
); err != nil {
return nil, err
}
return s.getSettings(userID)
}
func (s *Store) getSettings(userID int64) (*domain.UserSettings, error) {
row := s.db.QueryRow(`
SELECT user_id, export_accent, report_enabled, report_time, last_report_date,
rpg_enabled, league_opt_in, currency, hourly_rate, created_at, updated_at
FROM user_settings WHERE user_id = ?
`, userID)
var st domain.UserSettings
if err := row.Scan(
&st.UserID, &st.ExportAccent, &st.ReportEnabled, &st.ReportTime, &st.LastReportDate,
&st.RPGEnabled, &st.LeagueOptIn, &st.Currency, &st.HourlyRate, &st.CreatedAt, &st.UpdatedAt,
); err != nil {
return nil, err
}
return &st, nil
}
func (s *Store) UpdateSettings(st *domain.UserSettings) error {
_, err := s.db.Exec(`
UPDATE user_settings SET export_accent=?, report_enabled=?, report_time=?,
last_report_date=?, rpg_enabled=?, league_opt_in=?, currency=?,
hourly_rate=?, updated_at=unixepoch()
WHERE user_id=?
`, st.ExportAccent, st.ReportEnabled, st.ReportTime, st.LastReportDate,
st.RPGEnabled, st.LeagueOptIn, st.Currency, st.HourlyRate, st.UserID)
return err
}
func (s *Store) MarkReportSent(userID int64, date string) error {
st, err := s.GetOrCreateSettings(userID)
if err != nil {
return err
}
st.LastReportDate = date
return s.UpdateSettings(st)
}
// RPG Stats
func (s *Store) GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO user_rpg (user_id) VALUES (?)",
userID,
); err != nil {
return nil, err
}
return s.getRPGStats(userID)
}
func (s *Store) getRPGStats(userID int64) (*domain.RPGStats, error) {
row := s.db.QueryRow(`
SELECT user_id, xp, level, current_streak, longest_streak, total_work_seconds,
created_at, updated_at
FROM user_rpg WHERE user_id = ?
`, userID)
var st domain.RPGStats
if err := row.Scan(
&st.UserID, &st.XP, &st.Level, &st.CurrentStreak, &st.LongestStreak,
&st.TotalWorkSeconds, &st.CreatedAt, &st.UpdatedAt,
); err != nil {
return nil, err
}
return &st, nil
}
func (s *Store) UpdateRPGStats(st *domain.RPGStats) error {
_, err := s.db.Exec(`
UPDATE user_rpg SET xp=?, level=?, current_streak=?, longest_streak=?,
total_work_seconds=?, updated_at=unixepoch()
WHERE user_id=?
`, st.XP, st.Level, st.CurrentStreak, st.LongestStreak, st.TotalWorkSeconds, st.UserID)
return err
}
// Daily XP
func (s *Store) UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error {
_, err := s.db.Exec(`
INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, date) DO UPDATE SET
xp_earned = xp_earned + ?,
work_seconds = work_seconds + ?
`, userID, date, xpEarned, workSeconds, xpEarned, workSeconds)
return err
}
func (s *Store) SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error {
_, err := s.db.Exec(`
INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds)
VALUES (?, ?, 0, ?)
ON CONFLICT(user_id, date) DO UPDATE SET
work_seconds = ?
`, userID, date, workSeconds, workSeconds)
return err
}
func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) {
var total int64
err := s.db.QueryRow(
"SELECT COALESCE(SUM(work_seconds), 0) FROM daily_xp WHERE user_id=?", userID,
).Scan(&total)
return total, err
}
// Achievements
func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) {
var a domain.Achievement
err := s.db.QueryRow(
"SELECT id, code, name, description, icon FROM achievements WHERE code=?",
code,
).Scan(&a.ID, &a.Code, &a.Name, &a.Description, &a.Icon)
if err != nil {
return nil, err
}
return &a, nil
}
func (s *Store) GetUserAchievements(userID int64) ([]domain.UserAchievement, error) {
rows, err := s.db.Query(`
SELECT ua.id, ua.user_id, ua.achievement_id, a.code, a.name, a.description, ua.unlocked_at
FROM user_achievements ua
JOIN achievements a ON a.id = ua.achievement_id
WHERE ua.user_id = ?
ORDER BY ua.unlocked_at ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var uas []domain.UserAchievement
for rows.Next() {
var ua domain.UserAchievement
if err := rows.Scan(&ua.ID, &ua.UserID, &ua.AchievementID, &ua.Code, &ua.Name, &ua.Description, &ua.UnlockedAt); err != nil {
return nil, err
}
uas = append(uas, ua)
}
return uas, rows.Err()
}
func (s *Store) UnlockAchievement(userID int64, achievementID int64) (bool, error) {
res, err := s.db.Exec(
"INSERT OR IGNORE INTO user_achievements (user_id, achievement_id) VALUES (?, ?)",
userID, achievementID,
)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func (s *Store) HasAchievement(userID int64, code string) (bool, error) {
var count int
err := s.db.QueryRow(`
SELECT COUNT(1) FROM user_achievements ua
JOIN achievements a ON a.id = ua.achievement_id
WHERE ua.user_id=? AND a.code=?
`, userID, code).Scan(&count)
return count > 0, err
}
// League
func (s *Store) GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error) {
validOrders := map[string]bool{"xp": true, "level": true, "streak": true, "hours": true}
if !validOrders[orderBy] {
orderBy = "xp"
}
col := "r.xp"
switch orderBy {
case "level":
col = "r.level"
case "streak":
col = "r.current_streak"
case "hours":
col = "r.total_work_seconds"
}
query := fmt.Sprintf(`
SELECT u.id, u.username, r.xp, r.level, r.current_streak, r.total_work_seconds
FROM user_rpg r
JOIN users u ON u.id = r.user_id
JOIN user_settings s ON s.user_id = r.user_id
WHERE s.league_opt_in = 1
ORDER BY %s DESC, u.username ASC
`, col)
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []domain.LeagueEntry
for rows.Next() {
var e domain.LeagueEntry
if err := rows.Scan(&e.UserID, &e.Username, &e.XP, &e.Level, &e.Streak, &e.TotalHours); err != nil {
return nil, err
}
e.TotalHours = float64(e.TotalHours) / 3600.0
entries = append(entries, e)
}
return entries, rows.Err()
}
// Work Types
func (s *Store) GetWorkTypes() ([]domain.WorkType, error) {
rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")
if err != nil {
return nil, err
}
defer rows.Close()
var wts []domain.WorkType
for rows.Next() {
var wt domain.WorkType
if err := rows.Scan(&wt.ID, &wt.Name, &wt.CreatedAt); err != nil {
return nil, err
}
wts = append(wts, wt)
}
return wts, rows.Err()
}
func (s *Store) GetWorkType(id int64) (*domain.WorkType, error) {
var wt domain.WorkType
err := s.db.QueryRow("SELECT id, name, created_at FROM work_types WHERE id=?", id).Scan(&wt.ID, &wt.Name, &wt.CreatedAt)
if err != nil {
return nil, err
}
return &wt, nil
}
// Days
func (s *Store) GetOrCreateDay(userID int64, date string) (*domain.Day, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)",
userID, date,
); err != nil {
return nil, err
}
return s.GetDay(userID, date)
}
func (s *Store) GetDay(userID int64, date string) (*domain.Day, error) {
row := s.db.QueryRow(`
SELECT id, user_id, date, is_day_off, day_off_reason,
current_work_type_id, min_break_threshold, created_at, updated_at
FROM days WHERE user_id=? AND date=?
`, userID, date)
var d domain.Day
if err := row.Scan(
&d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason,
&d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}
return &d, nil
}
func (s *Store) UpdateDay(day *domain.Day) error {
_, err := s.db.Exec(
"UPDATE days SET is_day_off=?, day_off_reason=?, current_work_type_id=?, min_break_threshold=?, updated_at=unixepoch() WHERE id=?",
day.IsDayOff, day.DayOffReason, day.CurrentWorkTypeID, day.MinBreakThreshold, day.ID,
)
return err
}
func (s *Store) SetDayOff(userID int64, date string, reason string) error {
if _, err := s.GetOrCreateDay(userID, date); err != nil {
return err
}
_, err := s.db.Exec(
"UPDATE days SET is_day_off=1, day_off_reason=?, updated_at=unixepoch() WHERE user_id=? AND date=?",
reason, userID, date,
)
return err
}
func (s *Store) RemoveDayOff(userID int64, date string) error {
_, err := s.db.Exec(
"UPDATE days SET is_day_off=0, day_off_reason='', updated_at=unixepoch() WHERE user_id=? AND date=?",
userID, date,
)
return err
}
func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]domain.Day, error) {
rows, err := s.db.Query(`
SELECT id, user_id, date, is_day_off, day_off_reason,
current_work_type_id, min_break_threshold, created_at, updated_at
FROM days
WHERE user_id=? AND date >= ? AND date <= ?
ORDER BY date ASC
`, userID, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
var days []domain.Day
for rows.Next() {
var d domain.Day
if err := rows.Scan(
&d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason,
&d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}
days = append(days, d)
}
return days, rows.Err()
}
func (s *Store) GetDatesWithEvents(userID int64) ([]string, error) {
rows, err := s.db.Query(`
SELECT DISTINCT d.date
FROM days d
JOIN events e ON e.day_id = d.id
WHERE d.user_id = ?
ORDER BY d.date ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var dates []string
for rows.Next() {
var date string
if err := rows.Scan(&date); err != nil {
return nil, err
}
dates = append(dates, date)
}
return dates, rows.Err()
}
func (s *Store) GetDistinctEventDates(userID int64, startDate, endDate string) ([]string, error) {
rows, err := s.db.Query(
"SELECT DISTINCT d.date FROM days d JOIN events e ON e.day_id = d.id WHERE d.user_id=? AND d.date >= ? AND d.date <= ? ORDER BY d.date",
userID, startDate, endDate,
)
if err != nil {
return nil, err
}
defer rows.Close()
var dates []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err != nil {
return nil, err
}
dates = append(dates, d)
}
return dates, rows.Err()
}
// Events
func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error {
_, err := s.db.Exec(
"INSERT INTO events (user_id, day_id, event_type, work_type_id, occurred_at, note) VALUES (?, ?, ?, ?, ?, ?)",
userID, dayID, eventType, workTypeID, occurredAt, note,
)
return err
}
func (s *Store) UpdateEventWorkType(eventID, workTypeID int64) error {
_, err := s.db.Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID)
return err
}
func (s *Store) UpdateEventTime(eventID, occurredAt int64) error {
_, err := s.db.Exec("UPDATE events SET occurred_at=? WHERE id=?", occurredAt, eventID)
return err
}
func (s *Store) DeleteEvent(eventID int64) error {
_, err := s.db.Exec("DELETE FROM events WHERE id=?", eventID)
return err
}
func (s *Store) GetEventByID(eventID, userID int64) (*domain.Event, error) {
row := s.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 domain.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 (s *Store) GetLastEvent(userID int64) (*domain.Event, error) {
row := s.db.QueryRow(`
SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
FROM events
WHERE user_id = ?
ORDER BY occurred_at DESC
LIMIT 1
`, userID)
var e domain.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 {
if errors.Is(err, sql.ErrNoRows) {
return nil, sql.ErrNoRows
}
return nil, err
}
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
return &e, nil
}
func (s *Store) EventsForDayByDate(userID int64, date string) ([]domain.Event, error) {
rows, err := s.db.Query(`
SELECT e.id, e.user_id, e.day_id, e.event_type, e.work_type_id, e.occurred_at, e.note, e.created_at
FROM events e
JOIN days d ON d.id = e.day_id
WHERE d.user_id = ? AND d.date = ?
ORDER BY e.occurred_at ASC
`, userID, date)
if err != nil {
return nil, err
}
defer rows.Close()
var events []domain.Event
for rows.Next() {
var e domain.Event
var wt sql.NullInt64
if err := rows.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
}
events = append(events, e)
}
return events, rows.Err()
}
func (s *Store) EventsForDayByDayID(dayID int64) ([]domain.Event, error) {
rows, err := s.db.Query(`
SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
FROM events
WHERE day_id = ?
ORDER BY occurred_at ASC
`, dayID)
if err != nil {
return nil, err
}
defer rows.Close()
var events []domain.Event
for rows.Next() {
var e domain.Event
var wt sql.NullInt64
if err := rows.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
}
events = append(events, e)
}
return events, rows.Err()
}
func (s *Store) SetEventNote(eventID int64, note string) error {
_, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID)
return err
}