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:
35
README.md
35
README.md
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
1087
internal/db/store.go
1087
internal/db/store.go
File diff suppressed because it is too large
Load Diff
24
internal/domain/burnout.go
Normal file
24
internal/domain/burnout.go
Normal 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"
|
||||
}
|
||||
19
internal/domain/constants.go
Normal file
19
internal/domain/constants.go
Normal 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
326
internal/domain/dateutil.go
Normal 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
|
||||
}
|
||||
238
internal/domain/dateutil_test.go
Normal file
238
internal/domain/dateutil_test.go
Normal 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
76
internal/domain/rpg.go
Normal 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
|
||||
}
|
||||
@@ -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
51
internal/domain/salary.go
Normal 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], ""
|
||||
}
|
||||
@@ -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
68
internal/domain/totals.go
Normal 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}
|
||||
}
|
||||
@@ -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
188
internal/domain/types.go
Normal 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
207
internal/handler/burnout.go
Normal 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
|
||||
}
|
||||
@@ -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
222
internal/handler/clock.go
Normal 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
|
||||
}
|
||||
@@ -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
453
internal/handler/handler.go
Normal 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)
|
||||
}
|
||||
111
internal/handler/keyboard.go
Normal file
111
internal/handler/keyboard.go
Normal 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}
|
||||
}
|
||||
@@ -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
91
internal/handler/note.go
Normal 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
169
internal/handler/report.go
Normal 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
336
internal/handler/rpg.go
Normal 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")
|
||||
}
|
||||
}
|
||||
95
internal/handler/salary.go
Normal file
95
internal/handler/salary.go
Normal 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,
|
||||
}
|
||||
}
|
||||
720
internal/handler/settings.go
Normal file
720
internal/handler/settings.go
Normal 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)
|
||||
}
|
||||
@@ -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")
|
||||
75
internal/handler/worktype.go
Normal file
75
internal/handler/worktype.go
Normal 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)
|
||||
}
|
||||
59
internal/repo/interface.go
Normal file
59
internal/repo/interface.go
Normal 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
221
internal/repo/migrations.go
Normal 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, ¬null, &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, ¬e, &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
607
internal/repo/store.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user