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
|
│ ├── main.go Entrypoint, polling/webhook, scheduler
|
||||||
│ └── webhook.go Webhook mode with TLS support
|
│ └── webhook.go Webhook mode with TLS support
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── bot/
|
│ ├── domain/ Pure business logic, no external dependencies
|
||||||
│ │ ├── handlers.go Message/callback routing, views, menu builders
|
│ │ ├── types.go Core domain types (User, Event, Day, RPGStats, ...)
|
||||||
│ │ ├── clock.go Clock in/out, day off, report
|
│ │ ├── constants.go App-wide constants and thresholds
|
||||||
|
│ │ ├── dateutil.go Gregorian/Jalali/Hijri calendar conversions
|
||||||
│ │ ├── totals.go Break/work computation state machine
|
│ │ ├── 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
|
│ │ ├── calendar.go History view and inline event editing
|
||||||
│ │ ├── export.go Excel (.xlsx) report generation
|
│ │ ├── export.go Excel (.xlsx) report generation
|
||||||
│ │ ├── dateutil.go Gregorian/Jalali/Hijri conversions & calendars
|
│ │ ├── rpg.go RPG stats, achievements, aggregates
|
||||||
│ │ ├── rpg.go XP, levels, streaks, achievements
|
|
||||||
│ │ ├── league.go WorkTime League leaderboard
|
│ │ ├── league.go WorkTime League leaderboard
|
||||||
│ │ ├── salary.go Salary estimation, rate/currency commands
|
│ │ ├── salary.go Salary estimation view
|
||||||
│ │ ├── burnout.go Burnout assessment model
|
│ │ ├── burnout.go Burnout assessment view
|
||||||
│ │ ├── settings.go Settings menu with inline pickers
|
│ │ ├── settings.go Settings menu with inline pickers
|
||||||
│ │ ├── summary.go Monthly summary
|
│ │ ├── summary.go Monthly summary
|
||||||
│ │ ├── report.go Daily report builder
|
│ │ ├── report.go Daily report builder, status text
|
||||||
│ │ └── sanitize.go Input sanitization helpers
|
│ │ ├── note.go Note command, edit command
|
||||||
│ └── db/
|
│ │ ├── worktype.go Work type selection
|
||||||
│ ├── store.go SQLite store (data access layer)
|
│ │ └── keyboard.go Inline keyboard builders
|
||||||
│ └── migrations/ Goose-managed SQL migrations
|
│ └── 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
|
│ ├── 001_init.sql Initial schema
|
||||||
│ └── 002_features.sql RPG, achievements, user_settings
|
│ └── 002_features.sql RPG, achievements, user_settings
|
||||||
├── .gitea/workflows/
|
├── .gitea/workflows/
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
"worktimeBot/internal/bot"
|
"worktimeBot/internal/handler"
|
||||||
"worktimeBot/internal/db"
|
"worktimeBot/internal/repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -23,12 +23,13 @@ func main() {
|
|||||||
AddSource: true,
|
AddSource: true,
|
||||||
Level: slog.LevelInfo,
|
Level: slog.LevelInfo,
|
||||||
})))
|
})))
|
||||||
|
|
||||||
godotenv.Load()
|
godotenv.Load()
|
||||||
|
|
||||||
token := os.Getenv("BOT_TOKEN")
|
token := os.Getenv("BOT_TOKEN")
|
||||||
dbPath := getEnvDefault("DB_PATH", "db.sqlite3")
|
dbPath := getEnvDefault("DB_PATH", "db.sqlite3")
|
||||||
|
|
||||||
dbStore, err := db.NewStore(dbPath)
|
store, err := repo.NewStore(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to open database", "path", dbPath, "error", err)
|
slog.Error("failed to open database", "path", dbPath, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -36,7 +37,7 @@ func main() {
|
|||||||
|
|
||||||
allowedUsers := parseAllowedUsers(os.Getenv("BOT_ALLOWED_USERS"))
|
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) {
|
dispatch := func(ctx context.Context, b *tgbot.Bot, update *models.Update) {
|
||||||
if update.Message != nil {
|
if update.Message != nil {
|
||||||
@@ -53,7 +54,7 @@ func main() {
|
|||||||
os.Exit(1)
|
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)
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -64,7 +65,7 @@ func main() {
|
|||||||
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
|
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
|
||||||
slog.Warn("failed to delete webhook", "error", err)
|
slog.Warn("failed to delete webhook", "error", err)
|
||||||
}
|
}
|
||||||
go dailyReportScheduler(ctx, h, dbStore)
|
go dailyReportScheduler(ctx, h, store)
|
||||||
slog.Info("bot started",
|
slog.Info("bot started",
|
||||||
"allowed_users", len(allowedUsers),
|
"allowed_users", len(allowedUsers),
|
||||||
"mode", "polling",
|
"mode", "polling",
|
||||||
@@ -72,11 +73,11 @@ func main() {
|
|||||||
b.Start(ctx)
|
b.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
dbStore.Close()
|
store.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// dailyReportScheduler runs a periodic ticker that triggers daily report checks every 30 seconds.
|
// 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)
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
defer ticker.Stop()
|
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.
|
// 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()
|
users, err := store.GetAllUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("report scheduler: get users", "error", err)
|
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,
|
"user_id", u.ID, "chat_id", u.ChatID, "timezone", u.Timezone,
|
||||||
)
|
)
|
||||||
h.SendDailyReport(ctx, u.ID, u.ChatID)
|
h.SendDailyReport(ctx, u.ID, u.ChatID)
|
||||||
|
store.MarkReportSent(u.ID, today)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
|
|
||||||
tgbot "github.com/go-telegram/bot"
|
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.
|
// 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 {
|
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
|
||||||
slog.Warn("failed to delete webhook", "error", err)
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -20,9 +20,9 @@ func TestXpForLevel(t *testing.T) {
|
|||||||
{50, 127500},
|
{50, 127500},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
got := xpForLevel(c.n)
|
got := XpForLevel(c.n)
|
||||||
if got != c.want {
|
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},
|
{127500, 50},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
got := levelForXP(c.xp)
|
got := LevelForXP(c.xp)
|
||||||
if got != c.want {
|
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) {
|
func TestLevelForXP_Monotonic(t *testing.T) {
|
||||||
for n := 0; n <= 100; n++ {
|
for n := 0; n <= 100; n++ {
|
||||||
xp := xpForLevel(n)
|
xp := XpForLevel(n)
|
||||||
l := levelForXP(xp)
|
l := LevelForXP(xp)
|
||||||
if l != n {
|
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) {
|
func TestXpForWorkSeconds(t *testing.T) {
|
||||||
if got := xpForWorkSeconds(0); got != 0 {
|
if got := XpForWorkSeconds(0); got != 0 {
|
||||||
t.Errorf("xpForWorkSeconds(0) = %d, want 0", got)
|
t.Errorf("XpForWorkSeconds(0) = %d, want 0", got)
|
||||||
}
|
}
|
||||||
if got := xpForWorkSeconds(3600); got != 3600 {
|
if got := XpForWorkSeconds(3600); got != 3600 {
|
||||||
t.Errorf("xpForWorkSeconds(3600) = %d, want 3600", got)
|
t.Errorf("XpForWorkSeconds(3600) = %d, want 3600", got)
|
||||||
}
|
}
|
||||||
if got := xpForWorkSeconds(1800); got != 1800 {
|
if got := XpForWorkSeconds(1800); got != 1800 {
|
||||||
t.Errorf("xpForWorkSeconds(1800) = %d, want 1800", got)
|
t.Errorf("XpForWorkSeconds(1800) = %d, want 1800", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +84,9 @@ func TestStreakBonus(t *testing.T) {
|
|||||||
{20, 500},
|
{20, 500},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
got := streakBonus(c.streak)
|
got := StreakBonus(c.streak)
|
||||||
if got != c.want {
|
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", "$"},
|
{"$ USD", "$"},
|
||||||
{"€ EUR", "€"},
|
{"\u20AC EUR", "\u20AC"},
|
||||||
{"£", "£"},
|
{"\u00A3", "\u00A3"},
|
||||||
{"Toman", "Toman"},
|
{"Toman", "Toman"},
|
||||||
{" ", "$"},
|
{" ", "$"},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
got := currencySymbol(c.currency)
|
got := CurrencySymbol(c.currency)
|
||||||
if got != c.want {
|
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) {
|
func TestComputeDailySalary(t *testing.T) {
|
||||||
// 1 hour at $25/hr
|
got := ComputeDailySalary(3600, 25.0)
|
||||||
got := computeDailySalary(3600, 25.0)
|
|
||||||
if got != 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 {
|
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 {
|
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 {
|
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) {
|
func TestSanitizeNote(t *testing.T) {
|
||||||
// Trims spaces
|
|
||||||
if got := SanitizeNote(" hello "); got != "hello" {
|
if got := SanitizeNote(" hello "); got != "hello" {
|
||||||
t.Errorf("SanitizeNote trim: got %q", got)
|
t.Errorf("SanitizeNote trim: got %q", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removes control chars except newline/tab
|
|
||||||
if got := SanitizeNote("he\x00llo\nworld\t!"); got != "hello\nworld\t!" {
|
if got := SanitizeNote("he\x00llo\nworld\t!"); got != "hello\nworld\t!" {
|
||||||
t.Errorf("SanitizeNote control: got %q", got)
|
t.Errorf("SanitizeNote control: got %q", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserves newlines and tabs
|
|
||||||
if got := SanitizeNote("line1\nline2\tindented"); got != "line1\nline2\tindented" {
|
if got := SanitizeNote("line1\nline2\tindented"); got != "line1\nline2\tindented" {
|
||||||
t.Errorf("SanitizeNote newline/tab: got %q", got)
|
t.Errorf("SanitizeNote newline/tab: got %q", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncates long notes
|
long := make([]byte, 600)
|
||||||
long := string(make([]byte, 600))
|
|
||||||
for i := range long {
|
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 {
|
if len(got) > 500 {
|
||||||
t.Errorf("SanitizeNote length: got %d, want <= 500", len(got))
|
t.Errorf("SanitizeNote length: got %d, want <= 500", len(got))
|
||||||
}
|
}
|
||||||
@@ -186,19 +178,19 @@ func TestParseCurrencyInput(t *testing.T) {
|
|||||||
{"$ USD", "$ USD", true},
|
{"$ USD", "$ USD", true},
|
||||||
{"T Toman", "T Toman", true},
|
{"T Toman", "T Toman", true},
|
||||||
{"IRR Rial", "IRR Rial", true},
|
{"IRR Rial", "IRR Rial", true},
|
||||||
{"€ EUR", "€ EUR", true},
|
{"\u20AC EUR", "\u20AC EUR", true},
|
||||||
{"", "", false},
|
{"", "", false},
|
||||||
{"$", "", false},
|
{"$", "", false},
|
||||||
{" ", "", false},
|
{" ", "", false},
|
||||||
{"$$$ TooLongCode", "", false},
|
{"$$$ TooLongCode", "", false},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
got, errMsg := parseCurrencyInput(c.input)
|
got, errMsg := ParseCurrencyInput(c.input)
|
||||||
if c.ok && (got != c.want || errMsg != "") {
|
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 == "" {
|
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"},
|
{100, 130, 0, "-23"},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
pts, dir := computeTrendFromAvgs(c.recent, c.older)
|
pts, dir := ComputeTrendFromAvgs(c.recent, c.older)
|
||||||
if pts != c.wantPts {
|
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) {
|
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) {
|
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 {
|
if cur != 0 || longest != 0 {
|
||||||
t.Errorf("empty dates: got (%d, %d), want (0, 0)", cur, longest)
|
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) {
|
func TestComputeStreakFromDates_CurrentOnly(t *testing.T) {
|
||||||
dates := []string{"2026-06-25", "2026-06-26"}
|
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 {
|
if cur != 2 {
|
||||||
t.Errorf("current streak = %d, want 2", cur)
|
t.Errorf("current streak = %d, want 2", cur)
|
||||||
}
|
}
|
||||||
@@ -247,7 +239,7 @@ func TestComputeStreakFromDates_CurrentOnly(t *testing.T) {
|
|||||||
|
|
||||||
func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) {
|
func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) {
|
||||||
dates := []string{"2026-06-23", "2026-06-25", "2026-06-26"}
|
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 {
|
if cur != 2 {
|
||||||
t.Errorf("current streak = %d, want 2 (today and yesterday)", cur)
|
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) {
|
func TestComputeStreakFromDates_NoEventsToday(t *testing.T) {
|
||||||
dates := []string{"2026-06-24", "2026-06-25"}
|
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 {
|
if cur != 0 {
|
||||||
t.Errorf("current streak = %d, want 0 (no events today)", cur)
|
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) {
|
func TestComputeStreakFromDates_LongStreak(t *testing.T) {
|
||||||
dates := []string{}
|
dates := make([]string, 0, 10)
|
||||||
for d := 1; d <= 10; d++ {
|
for d := 1; d <= 10; d++ {
|
||||||
dates = append(dates, fmt.Sprintf("2026-06-%02d", 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 {
|
if cur != 10 {
|
||||||
t.Errorf("current streak = %d, want 10", cur)
|
t.Errorf("current streak = %d, want 10", cur)
|
||||||
}
|
}
|
||||||
@@ -284,10 +276,10 @@ func TestComputeStreakFromDates_LongStreak(t *testing.T) {
|
|||||||
func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) {
|
func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) {
|
||||||
dates := []string{}
|
dates := []string{}
|
||||||
for d := 1; d <= 5; d++ {
|
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")
|
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 {
|
if cur != 2 {
|
||||||
t.Errorf("current streak = %d, want 2", cur)
|
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)
|
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 (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxNoteLength = 500
|
|
||||||
const maxUsernameLength = 64
|
|
||||||
|
|
||||||
// SanitizeNote cleans user-provided note text: trims whitespace, limits length,
|
// SanitizeNote cleans user-provided note text: trims whitespace, limits length,
|
||||||
// and removes control characters (keeps newlines).
|
// and removes control characters (keeps newlines).
|
||||||
func SanitizeNote(s string) string {
|
func SanitizeNote(s string) string {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if len(s) > maxNoteLength {
|
if len(s) > MaxNoteLength {
|
||||||
s = s[:maxNoteLength]
|
s = s[:MaxNoteLength]
|
||||||
}
|
}
|
||||||
return strings.Map(func(r rune) rune {
|
return strings.Map(func(r rune) rune {
|
||||||
if r == '\n' || r == '\t' || r == ' ' {
|
if r == '\n' || r == '\t' || r == ' ' {
|
||||||
@@ -29,8 +26,8 @@ func SanitizeNote(s string) string {
|
|||||||
// SanitizeDisplayName cleans a display name (username) for safe rendering.
|
// SanitizeDisplayName cleans a display name (username) for safe rendering.
|
||||||
func SanitizeDisplayName(s string) string {
|
func SanitizeDisplayName(s string) string {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if len(s) > maxUsernameLength {
|
if len(s) > MaxUsernameLength {
|
||||||
s = s[:maxUsernameLength]
|
s = s[:MaxUsernameLength]
|
||||||
}
|
}
|
||||||
return strings.Map(func(r rune) rune {
|
return strings.Map(func(r rune) rune {
|
||||||
if unicode.IsControl(r) {
|
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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"worktimeBot/internal/db"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func mkEvent(typ string, ts int64) db.Event {
|
func mkEvent(typ string, ts int64) Event {
|
||||||
return db.Event{EventType: typ, OccurredAt: ts}
|
return Event{EventType: typ, OccurredAt: ts}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeDailyTotals_Empty(t *testing.T) {
|
func TestComputeDailyTotals_Empty(t *testing.T) {
|
||||||
@@ -18,14 +16,14 @@ func TestComputeDailyTotals_Empty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeDailyTotals_NoEvents(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 {
|
if got.TotalSeconds != 0 || got.BreakSeconds != 0 {
|
||||||
t.Errorf("no events: got %+v", got)
|
t.Errorf("no events: got %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeDailyTotals_SingleInOut(t *testing.T) {
|
func TestComputeDailyTotals_SingleInOut(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
}
|
}
|
||||||
@@ -39,12 +37,11 @@ func TestComputeDailyTotals_SingleInOut(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeDailyTotals_MissingFirstIn(t *testing.T) {
|
func TestComputeDailyTotals_MissingFirstIn(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
mkEvent("in", 300),
|
mkEvent("in", 300),
|
||||||
mkEvent("out", 400),
|
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)
|
got := ComputeDailyTotals(events, 0, 100, 500)
|
||||||
if got.TotalSeconds != 200 {
|
if got.TotalSeconds != 200 {
|
||||||
t.Errorf("missing first in: TotalSeconds=%d, want 200", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_MissingLastOut(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
mkEvent("in", 300),
|
mkEvent("in", 300),
|
||||||
}
|
}
|
||||||
// synthetic "out" at dayEnd=500 => 100s + 200s
|
|
||||||
got := ComputeDailyTotals(events, 0, 0, 500)
|
got := ComputeDailyTotals(events, 0, 0, 500)
|
||||||
if got.TotalSeconds != 300 {
|
if got.TotalSeconds != 300 {
|
||||||
t.Errorf("missing last out: TotalSeconds=%d, want 300", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_BreakUnderThreshold(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
mkEvent("in", 250),
|
mkEvent("in", 250),
|
||||||
mkEvent("out", 400),
|
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)
|
got := ComputeDailyTotals(events, 60, 0, 0)
|
||||||
if got.TotalSeconds != 300 {
|
if got.TotalSeconds != 300 {
|
||||||
t.Errorf("break under threshold: TotalSeconds=%d, want 300", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_BreakOverThreshold(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
mkEvent("in", 300),
|
mkEvent("in", 300),
|
||||||
mkEvent("out", 400),
|
mkEvent("out", 400),
|
||||||
}
|
}
|
||||||
// break = 100s, threshold = 60s => counted as break
|
|
||||||
got := ComputeDailyTotals(events, 60, 0, 0)
|
got := ComputeDailyTotals(events, 60, 0, 0)
|
||||||
if got.TotalSeconds != 200 {
|
if got.TotalSeconds != 200 {
|
||||||
t.Errorf("break over threshold: TotalSeconds=%d, want 200", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_ConsecutiveOuts(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
mkEvent("out", 250),
|
mkEvent("out", 250),
|
||||||
mkEvent("in", 300),
|
mkEvent("in", 300),
|
||||||
mkEvent("out", 400),
|
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)
|
got := ComputeDailyTotals(events, 0, 0, 0)
|
||||||
if got.TotalSeconds != 200 {
|
if got.TotalSeconds != 200 {
|
||||||
t.Errorf("consecutive outs: TotalSeconds=%d, want 200", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_ConsecutiveIns(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
mkEvent("in", 150),
|
mkEvent("in", 150),
|
||||||
mkEvent("out", 300),
|
mkEvent("out", 300),
|
||||||
}
|
}
|
||||||
// second "in" while already working is ignored
|
|
||||||
got := ComputeDailyTotals(events, 0, 0, 0)
|
got := ComputeDailyTotals(events, 0, 0, 0)
|
||||||
if got.TotalSeconds != 200 {
|
if got.TotalSeconds != 200 {
|
||||||
t.Errorf("consecutive ins: TotalSeconds=%d, want 200", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_SingleInEndOfDay(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
}
|
}
|
||||||
// missing out => synthetic out at dayEnd
|
|
||||||
got := ComputeDailyTotals(events, 0, 0, 500)
|
got := ComputeDailyTotals(events, 0, 0, 500)
|
||||||
if got.TotalSeconds != 400 {
|
if got.TotalSeconds != 400 {
|
||||||
t.Errorf("single in with dayEnd: TotalSeconds=%d, want 400", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_SingleOutStartOfDay(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
}
|
}
|
||||||
// missing in => synthetic in at dayStart
|
|
||||||
got := ComputeDailyTotals(events, 0, 100, 500)
|
got := ComputeDailyTotals(events, 0, 100, 500)
|
||||||
if got.TotalSeconds != 100 {
|
if got.TotalSeconds != 100 {
|
||||||
t.Errorf("single out with dayStart: TotalSeconds=%d, want 100", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_ZeroThreshold(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
mkEvent("in", 250),
|
mkEvent("in", 250),
|
||||||
mkEvent("out", 400),
|
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)
|
got := ComputeDailyTotals(events, 0, 0, 0)
|
||||||
if got.TotalSeconds != 250 {
|
if got.TotalSeconds != 250 {
|
||||||
t.Errorf("zero threshold: TotalSeconds=%d, want 250", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_LargeBreak(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
mkEvent("in", 10000),
|
mkEvent("in", 10000),
|
||||||
@@ -187,7 +171,7 @@ func TestComputeDailyTotals_LargeBreak(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeDailyTotals_MultipleSessions(t *testing.T) {
|
func TestComputeDailyTotals_MultipleSessions(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
mkEvent("in", 300),
|
mkEvent("in", 300),
|
||||||
@@ -195,7 +179,6 @@ func TestComputeDailyTotals_MultipleSessions(t *testing.T) {
|
|||||||
mkEvent("in", 500),
|
mkEvent("in", 500),
|
||||||
mkEvent("out", 600),
|
mkEvent("out", 600),
|
||||||
}
|
}
|
||||||
// 3 work sessions: 100+100+100 = 300s
|
|
||||||
got := ComputeDailyTotals(events, 0, 0, 0)
|
got := ComputeDailyTotals(events, 0, 0, 0)
|
||||||
if got.TotalSeconds != 300 {
|
if got.TotalSeconds != 300 {
|
||||||
t.Errorf("multiple sessions: TotalSeconds=%d, want 300", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_OnlyOut(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("out", 200),
|
mkEvent("out", 200),
|
||||||
}
|
}
|
||||||
// no dayStart => no synthetic in => idle -> out triggers OnBreak
|
|
||||||
got := ComputeDailyTotals(events, 0, 0, 0)
|
got := ComputeDailyTotals(events, 0, 0, 0)
|
||||||
if got.TotalSeconds != 0 {
|
if got.TotalSeconds != 0 {
|
||||||
t.Errorf("only out no dayStart: TotalSeconds=%d, want 0", got.TotalSeconds)
|
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) {
|
func TestComputeDailyTotals_OnlyIn(t *testing.T) {
|
||||||
events := []db.Event{
|
events := []Event{
|
||||||
mkEvent("in", 100),
|
mkEvent("in", 100),
|
||||||
}
|
}
|
||||||
// no dayEnd => no synthetic out => still working at end
|
|
||||||
got := ComputeDailyTotals(events, 0, 0, 0)
|
got := ComputeDailyTotals(events, 0, 0, 0)
|
||||||
if got.TotalSeconds != 0 {
|
if got.TotalSeconds != 0 {
|
||||||
t.Errorf("only in no dayEnd: TotalSeconds=%d, want 0", got.TotalSeconds)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -10,245 +10,37 @@ import (
|
|||||||
|
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
|
|
||||||
"worktimeBot/internal/db"
|
"worktimeBot/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleHistoryMsg shows the calendar view (new message).
|
// -- History command and callback --
|
||||||
func (h *Handler) handleHistoryMsg(ctx context.Context, msg *models.Message) {
|
|
||||||
h.sendCalendar(ctx, msg.Chat.ID, 0, 0, 0)
|
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, cbID string, user *domain.User) {
|
||||||
func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
defer h.answerCb(ctx, cbID)
|
||||||
defer h.answerCb(ctx, callbackID)
|
|
||||||
h.editCalendar(ctx, chatID, msgID, 0, 0)
|
h.editCalendar(ctx, chatID, msgID, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEditMsg parses a /edit YYYY-MM-DD command and shows that day's events.
|
// -- Calendar rendering --
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, year, month int) {
|
||||||
user, err := h.getOrCreateUser(chatID)
|
user, err := h.getOrCreateUser(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loc := loadLocation(user.Timezone)
|
loc := h.loadLocation(user.Timezone)
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
|
|
||||||
if year == 0 || month == 0 {
|
if year == 0 || month == 0 {
|
||||||
switch user.Calendar {
|
switch user.Calendar {
|
||||||
case "jalali":
|
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
|
year, month = jy, jm
|
||||||
case "hijri":
|
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
|
year, month = hy, hm
|
||||||
default:
|
default:
|
||||||
year, month = now.Year(), int(now.Month())
|
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
|
cal := user.Calendar
|
||||||
cm := buildCalendarMonth(cal, year, month)
|
cm := domain.BuildCalendarMonth(cal, year, month)
|
||||||
|
text := cm.Title
|
||||||
text := cm.title
|
todayKey := now.Format(domain.DateLayout)
|
||||||
todayKey := now.Format(DateLayout)
|
|
||||||
|
|
||||||
hasEvent := make(map[string]bool)
|
hasEvent := make(map[string]bool)
|
||||||
startDate, endDate := monthGregorianRange(cal, year, month)
|
startDate, endDate := domain.MonthGregorianRange(cal, year, month)
|
||||||
dates, err := h.DB.GetDistinctEventDates(user.ID, startDate, endDate)
|
dates, err := h.Repo.GetDistinctEventDates(user.ID, startDate, endDate)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, d := range dates {
|
for _, d := range dates {
|
||||||
hasEvent[d] = true
|
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{}
|
headerRow := []models.InlineKeyboardButton{}
|
||||||
for _, wn := range cm.weekDays {
|
for _, wn := range cm.WeekDays {
|
||||||
headerRow = append(headerRow, models.InlineKeyboardButton{Text: wn, CallbackData: "noop"})
|
headerRow = append(headerRow, models.InlineKeyboardButton{Text: wn, CallbackData: "noop"})
|
||||||
}
|
}
|
||||||
kbRows = append(kbRows, headerRow)
|
kbRows = append(kbRows, headerRow)
|
||||||
|
|
||||||
// Day cells
|
|
||||||
row := []models.InlineKeyboardButton{}
|
row := []models.InlineKeyboardButton{}
|
||||||
for i, d := range cm.days {
|
for i, d := range cm.Days {
|
||||||
if d.dayNum == 0 {
|
if d.DayNum == 0 {
|
||||||
row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"})
|
row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"})
|
||||||
} else {
|
} else {
|
||||||
label := fmt.Sprintf("%d", d.dayNum)
|
label := fmt.Sprintf("%d", d.DayNum)
|
||||||
if d.date == todayKey {
|
if d.Date == todayKey {
|
||||||
label = fmt.Sprintf("[%d]", d.dayNum)
|
label = fmt.Sprintf("[%d]", d.DayNum)
|
||||||
} else if hasEvent[d.date] {
|
} else if hasEvent[d.Date] {
|
||||||
label = fmt.Sprintf("%d*", d.dayNum)
|
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 {
|
if len(row) == 7 || i == len(cm.Days)-1 {
|
||||||
// Pad the last row to 7 columns so buttons render evenly
|
|
||||||
for len(row) < 7 {
|
for len(row) < 7 {
|
||||||
row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"})
|
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)
|
prevY, prevM, nextY, nextM := domain.NavMonth(year, month)
|
||||||
|
|
||||||
// Navigation row
|
|
||||||
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
||||||
{Text: "<", CallbackData: fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)},
|
{Text: "<", CallbackData: fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)},
|
||||||
{Text: "Today", CallbackData: "history"},
|
{Text: "Today", CallbackData: "history"},
|
||||||
{Text: ">", CallbackData: fmt.Sprintf("cal_next_%d_%d", nextY, nextM)},
|
{Text: ">", CallbackData: fmt.Sprintf("cal_next_%d_%d", nextY, nextM)},
|
||||||
})
|
})
|
||||||
|
|
||||||
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
||||||
{Text: "Back to Menu", CallbackData: "back_menu"},
|
{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)
|
h.sendOrEdit(ctx, chatID, msgID, text, &kb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// editCalendarYearPicker shows a 12-year grid for year selection.
|
// -- Calendar callbacks routing --
|
||||||
// refY/refM encode the view to return to via the Back button.
|
|
||||||
|
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) {
|
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)
|
backData := fmt.Sprintf("cal_years_back_%d_%d", refY, refM)
|
||||||
navSuffix := fmt.Sprintf("_%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)
|
h.editText(ctx, chatID, msgID, "Select year:", &kb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// editDayView opens an existing message as a day view.
|
// -- Day view --
|
||||||
func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *db.User, date string, loc *time.Location) {
|
|
||||||
|
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)
|
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 *domain.User, date string, loc *time.Location, isNewMsg bool) {
|
||||||
func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user *db.User, date string, loc *time.Location, isNewMsg bool) {
|
events, err := h.Repo.EventsForDayByDate(user.ID, date)
|
||||||
events, err := h.DB.EventsForDayByDate(user.ID, date)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isNewMsg {
|
if isNewMsg {
|
||||||
h.sendText(ctx, chatID, "Error loading events")
|
h.sendText(ctx, chatID, "Error loading events")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
text := fmt.Sprintf("Date: %s", domain.FormatDateForCalendar(date, user.Calendar))
|
||||||
text := fmt.Sprintf("Date: %s", formatDateForCalendar(date, user.Calendar))
|
|
||||||
if len(events) == 0 {
|
if len(events) == 0 {
|
||||||
text += "\nNo events for this day."
|
text += "\nNo events for this day."
|
||||||
kb := models.InlineKeyboardMarkup{
|
kb := models.InlineKeyboardMarkup{
|
||||||
@@ -368,14 +258,14 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user
|
|||||||
text += "\n\nEvents:"
|
text += "\n\nEvents:"
|
||||||
kbRows := [][]models.InlineKeyboardButton{}
|
kbRows := [][]models.InlineKeyboardButton{}
|
||||||
for _, e := range events {
|
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"
|
label := "IN"
|
||||||
if e.EventType == "out" {
|
if e.EventType == "out" {
|
||||||
label = "OUT"
|
label = "OUT"
|
||||||
}
|
}
|
||||||
wt := ""
|
wt := ""
|
||||||
if e.WorkTypeID != nil {
|
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 + "]"
|
wt = " [" + wtObj.Name + "]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,7 +274,6 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user
|
|||||||
note = " (" + e.Note + ")"
|
note = " (" + e.Note + ")"
|
||||||
}
|
}
|
||||||
text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note)
|
text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note)
|
||||||
|
|
||||||
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
||||||
{Text: fmt.Sprintf("Time %s", t), CallbackData: fmt.Sprintf("edit_time_%d", e.ID)},
|
{Text: fmt.Sprintf("Time %s", t), CallbackData: fmt.Sprintf("edit_time_%d", e.ID)},
|
||||||
{Text: "Type", CallbackData: fmt.Sprintf("edit_type_%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"},
|
{Text: "DEL", CallbackData: fmt.Sprintf("delete_%d", e.ID), Style: "danger"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
||||||
{Text: "Add IN", CallbackData: "addin_" + date},
|
{Text: "Add IN", CallbackData: "addin_" + date},
|
||||||
{Text: "Add OUT", CallbackData: "addout_" + date},
|
{Text: "Add OUT", CallbackData: "addout_" + date},
|
||||||
})
|
})
|
||||||
|
|
||||||
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
||||||
{Text: "Back to Calendar", CallbackData: "history"},
|
{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)
|
h.sendOrEdit(ctx, chatID, msgID, text, &kb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// editEventType shows a work type picker for a specific event.
|
// -- Event editing --
|
||||||
func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
|
||||||
wts, err := h.DB.GetWorkTypes()
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -425,54 +313,21 @@ func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, us
|
|||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
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 *domain.User, eventID, workTypeID int64, loc *time.Location) {
|
||||||
func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int, user *db.User, eventID, workTypeID int64, loc *time.Location) {
|
if err := h.Repo.UpdateEventWorkType(eventID, workTypeID); err != nil {
|
||||||
if err := h.DB.UpdateEventWorkType(eventID, workTypeID); err != nil {
|
|
||||||
slog.Error("failed to update event work type", "event_id", eventID, "error", err)
|
slog.Error("failed to update event work type", "event_id", eventID, "error", err)
|
||||||
h.sendDayView(ctx, chatID, msgID, user, "", loc, false)
|
h.sendDayView(ctx, chatID, msgID, user, "", loc, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event, err := h.getEventByID(user.ID, eventID)
|
event, err := h.Repo.GetEventByID(eventID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
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 (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
|
||||||
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) {
|
|
||||||
kb := buildHourPickerKeyboard(
|
kb := buildHourPickerKeyboard(
|
||||||
func(hh int) string { return fmt.Sprintf("edittm_%d_%02d", eventID, hh) },
|
func(hh int) string { return fmt.Sprintf("edittm_%d_%02d", eventID, hh) },
|
||||||
fmt.Sprintf("back_day_%d", eventID),
|
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)
|
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 *domain.User, eventID int64, hh int, loc *time.Location) {
|
||||||
func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh int, loc *time.Location) {
|
|
||||||
kb := buildMinutePickerKeyboard(
|
kb := buildMinutePickerKeyboard(
|
||||||
func(mm int) string { return fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm) },
|
func(mm int) string { return fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm) },
|
||||||
fmt.Sprintf("edit_time_%d", eventID),
|
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)
|
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 *domain.User, eventID int64, hh, mm int, loc *time.Location, cbID string) {
|
||||||
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.Repo.GetEventByID(eventID, user.ID)
|
||||||
event, err := h.getEventByID(user.ID, eventID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
t := time.Unix(event.OccurredAt, 0).In(loc)
|
||||||
newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, 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.Repo.EventsForDayByDate(user.ID, date)
|
||||||
events, err := h.DB.EventsForDayByDate(user.ID, date)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
if e.ID == eventID {
|
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)
|
eTime := time.Unix(e.OccurredAt, 0)
|
||||||
if newTime.Unix() == eTime.Unix() {
|
if newTime.Unix() == eTime.Unix() {
|
||||||
h.acknowledgeCallback(ctx, callbackID)
|
h.answerCb(ctx, cbID)
|
||||||
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that in/out events still alternate after the change
|
|
||||||
type eventPos struct {
|
type eventPos struct {
|
||||||
id int64
|
id int64
|
||||||
typ string
|
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})
|
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 i := 0; i < len(sorted); i++ {
|
||||||
for j := i + 1; j < len(sorted); j++ {
|
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) {
|
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++ {
|
for i := 1; i < len(sorted); i++ {
|
||||||
if sorted[i].typ == sorted[i-1].typ {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = h.DB.UpdateEventTime(eventID, newTime.Unix()); err != nil {
|
if err = h.Repo.UpdateEventTime(eventID, newTime.Unix()); err != nil {
|
||||||
slog.Error("failed to update event occurred_at", "event_id", eventID, "error", err)
|
slog.Error("failed to update event time", "event_id", eventID, "error", err)
|
||||||
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
||||||
return
|
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)
|
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// acknowledgeCallback sends an empty acknowledgement for a callback query.
|
// -- Event deletion --
|
||||||
func (h *Handler) acknowledgeCallback(ctx context.Context, callbackID string) {
|
|
||||||
h.answerCb(ctx, callbackID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// promptNote asks the user to type a note for the event, then re-renders the day view.
|
func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
|
||||||
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) {
|
|
||||||
kb := models.InlineKeyboardMarkup{
|
kb := models.InlineKeyboardMarkup{
|
||||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
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)
|
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 *domain.User, eventID int64, loc *time.Location) {
|
||||||
func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
event, err := h.Repo.GetEventByID(eventID, user.ID)
|
||||||
event, err := h.getEventByID(user.ID, eventID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
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)
|
slog.Error("failed to delete event", "event_id", eventID, "error", err)
|
||||||
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn if consecutive events now have the same type
|
events, err := h.Repo.EventsForDayByDate(user.ID, date)
|
||||||
events, err := h.DB.EventsForDayByDate(user.ID, date)
|
|
||||||
if err == nil && len(events) > 1 {
|
if err == nil && len(events) > 1 {
|
||||||
hasIssue := false
|
hasIssue := false
|
||||||
for i := 1; i < len(events); i++ {
|
for i := 1; i < len(events); i++ {
|
||||||
@@ -618,7 +441,7 @@ func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hasIssue {
|
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)
|
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 *domain.User, eventID int64, loc *time.Location) {
|
||||||
func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
event, err := h.Repo.GetEventByID(eventID, user.ID)
|
||||||
event, err := h.getEventByID(user.ID, eventID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
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.
|
// -- Note via pending input --
|
||||||
func (h *Handler) getEventByID(userID, eventID int64) (*db.Event, error) {
|
|
||||||
return h.DB.GetEventByID(eventID, userID)
|
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.
|
// -- Adding events --
|
||||||
func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, date, eventType string, loc *time.Location) {
|
|
||||||
|
func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, loc *time.Location) {
|
||||||
kb := buildHourPickerKeyboard(
|
kb := buildHourPickerKeyboard(
|
||||||
func(hh int) string { return fmt.Sprintf("addtm_%s_%s_%02d", eventType, date, hh) },
|
func(hh int) string { return fmt.Sprintf("addtm_%s_%s_%02d", eventType, date, hh) },
|
||||||
"cal_day_"+date,
|
"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)
|
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) handleAddEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, payload string, loc *time.Location) {
|
||||||
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) {
|
|
||||||
parts := strings.SplitN(payload, "_", 4)
|
parts := strings.SplitN(payload, "_", 4)
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
return
|
return
|
||||||
@@ -701,7 +498,6 @@ func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
h.addEventTimeMin(ctx, chatID, msgID, user, addDate, eventType, hh, loc)
|
h.addEventTimeMin(ctx, chatID, msgID, user, addDate, eventType, hh, loc)
|
||||||
return
|
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)
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
bot "github.com/go-telegram/bot"
|
tgbot "github.com/go-telegram/bot"
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
"github.com/xuri/excelize/v2"
|
"github.com/xuri/excelize/v2"
|
||||||
|
|
||||||
"worktimeBot/internal/db"
|
"worktimeBot/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type themeColors struct {
|
type themeColors struct {
|
||||||
@@ -92,16 +92,107 @@ func newStyle(f *excelize.File, s *excelize.Style) int {
|
|||||||
return id
|
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.SetCellValue(sheet, "A1", title)
|
||||||
f.MergeCell(sheet, "A1", "F1")
|
f.MergeCell(sheet, "A1", "F1")
|
||||||
f.SetCellStyle(sheet, "A1", "F1", s.title)
|
f.SetCellStyle(sheet, "A1", "F1", s.title)
|
||||||
f.SetRowHeight(sheet, 1, 30)
|
f.SetRowHeight(sheet, 1, 30)
|
||||||
|
|
||||||
headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"}
|
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)
|
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
|
||||||
f.SetCellValue(sheet, cell, h)
|
f.SetCellValue(sheet, cell, hdr)
|
||||||
f.SetCellStyle(sheet, cell, cell, s.header)
|
f.SetCellStyle(sheet, cell, cell, s.header)
|
||||||
}
|
}
|
||||||
f.SetRowHeight(sheet, 2, 22)
|
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)
|
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)
|
cellDate, _ := excelize.CoordinatesToCellName(1, row)
|
||||||
f.SetCellValue(sheet, cellDate, displayDate)
|
f.SetCellValue(sheet, cellDate, displayDate)
|
||||||
f.SetCellStyle(sheet, cellDate, cellDate, s.data)
|
f.SetCellStyle(sheet, cellDate, cellDate, s.data)
|
||||||
|
|
||||||
if len(events) > 0 {
|
if len(events) > 0 {
|
||||||
cellIn, _ := excelize.CoordinatesToCellName(2, row)
|
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)
|
f.SetCellStyle(sheet, cellIn, cellIn, s.data)
|
||||||
}
|
}
|
||||||
if len(events) > 0 {
|
if len(events) > 1 {
|
||||||
lastEvent := events[len(events)-1]
|
lastEvent := events[len(events)-1]
|
||||||
if lastEvent.EventType == "out" {
|
if lastEvent.EventType == "out" {
|
||||||
cellOut, _ := excelize.CoordinatesToCellName(3, row)
|
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)
|
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)
|
f.SetCellStyle(sheet, cellType, cellType, s.data)
|
||||||
|
|
||||||
if len(events) > 0 && day != nil {
|
if len(events) > 0 && day != nil {
|
||||||
var d time.Time
|
d, err := time.Parse(domain.DateLayout, day.Date)
|
||||||
d, _ = time.Parse(DateLayout, day.Date)
|
if err == nil {
|
||||||
dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix()
|
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()
|
dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix()
|
||||||
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
|
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
|
||||||
workSec = totals.TotalSeconds
|
workSec = totals.TotalSeconds
|
||||||
breakSec = totals.BreakSeconds
|
breakSec = totals.BreakSeconds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cellWork, _ := excelize.CoordinatesToCellName(5, row)
|
cellWork, _ := excelize.CoordinatesToCellName(5, row)
|
||||||
@@ -164,7 +256,7 @@ func writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typ
|
|||||||
return workSec, breakSec
|
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++
|
row++
|
||||||
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
|
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
|
||||||
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), s.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)
|
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) {
|
func fmtHHMM(seconds int64) string {
|
||||||
theme, ok := themes[accent]
|
hours := seconds / domain.SecondsPerHour
|
||||||
if !ok {
|
mins := (seconds % domain.SecondsPerHour) / 60
|
||||||
theme = themes["ocean"]
|
return fmt.Sprintf("%d:%02d", hours, mins)
|
||||||
}
|
|
||||||
|
|
||||||
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 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)
|
wtSeen := make(map[int64]bool)
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
if e.WorkTypeID != nil {
|
if e.WorkTypeID != nil {
|
||||||
@@ -263,7 +288,7 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(wtSeen) == 0 {
|
if len(wtSeen) == 0 {
|
||||||
wt, err := store.GetWorkType(day.CurrentWorkTypeID)
|
wt, err := h.Repo.GetWorkType(day.CurrentWorkTypeID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return wt.Name
|
return wt.Name
|
||||||
}
|
}
|
||||||
@@ -271,7 +296,7 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
|
|||||||
}
|
}
|
||||||
if len(wtSeen) == 1 {
|
if len(wtSeen) == 1 {
|
||||||
for id := range wtSeen {
|
for id := range wtSeen {
|
||||||
wt, err := store.GetWorkType(id)
|
wt, err := h.Repo.GetWorkType(id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return wt.Name
|
return wt.Name
|
||||||
}
|
}
|
||||||
@@ -280,85 +305,64 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
|
|||||||
return "mixed"
|
return "mixed"
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtHHMM(seconds int64) string {
|
// -- Commands --
|
||||||
hours := seconds / secondsPerHour
|
|
||||||
mins := (seconds % secondsPerHour) / 60
|
|
||||||
return fmt.Sprintf("%d:%02d", hours, mins)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtDDHHMM(seconds int64) string {
|
func (h *Handler) handleExport(ctx context.Context, msg *models.Message, user *domain.User) {
|
||||||
days := seconds / secondsPerDay
|
loc := h.loadLocation(user.Timezone)
|
||||||
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)
|
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
|
|
||||||
cy, cm := now.Year(), int(now.Month())
|
calY, calM := now.Year(), int(now.Month())
|
||||||
switch user.Calendar {
|
switch user.Calendar {
|
||||||
case "jalali":
|
case "jalali":
|
||||||
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
|
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
|
||||||
case "hijri":
|
case "hijri":
|
||||||
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
|
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
|
||||||
}
|
}
|
||||||
|
|
||||||
args := strings.Fields(msg.Text)
|
args := strings.Fields(msg.Text)
|
||||||
if len(args) > 0 && args[0][0] == '/' {
|
if len(args) >= 1 && args[0][0] == '/' {
|
||||||
args = args[1:]
|
args = args[1:]
|
||||||
}
|
}
|
||||||
if len(args) >= 1 {
|
if len(args) >= 1 {
|
||||||
if n, _ := fmt.Sscanf(args[0], "%d-%d", &cy, &cm); n == 2 {
|
if n, _ := fmt.Sscanf(args[0], "%d-%d", &calY, &calM); n == 2 {
|
||||||
if cy < 0 || cm < 1 || cm > 12 {
|
if calY < 0 || calM < 1 || calM > 12 {
|
||||||
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
|
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
|
||||||
return
|
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 *domain.User, calYear, calMonth int) {
|
||||||
func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.User, calYear, calMonth int) {
|
last, err := h.Repo.GetLastEvent(user.ID)
|
||||||
last, err := h.DB.GetLastEvent(user.ID)
|
|
||||||
if err == nil && last != nil && last.EventType == "in" {
|
if err == nil && last != nil && last.EventType == "in" {
|
||||||
h.sendText(ctx, chatID, "You are currently clocked in. Please clock out first, then try again.")
|
h.sendText(ctx, chatID, "You are currently clocked in. Please clock out first, then try again.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loc := loadLocation(user.Timezone)
|
loc := h.loadLocation(user.Timezone)
|
||||||
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
|
startStr, endStr := domain.MonthGregorianRange(user.Calendar, calYear, calMonth)
|
||||||
start, err := time.Parse(DateLayout, startStr)
|
start, err := time.Parse(domain.DateLayout, startStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
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 {
|
if err != nil {
|
||||||
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
||||||
end = startOfMonth.AddDate(0, 1, -1)
|
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)
|
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 {
|
if err != nil {
|
||||||
slog.Error("generate report", "error", err)
|
slog.Error("generate report", "error", err)
|
||||||
h.sendText(ctx, chatID, "Error generating report")
|
h.sendText(ctx, chatID, "Error generating report")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Info("report generated", "bytes", len(data))
|
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,
|
ChatID: chatID,
|
||||||
Document: &models.InputFileUpload{
|
Document: &models.InputFileUpload{
|
||||||
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
|
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.
|
// -- Callbacks --
|
||||||
func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
|
||||||
defer h.answerCb(ctx, callbackID)
|
func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
||||||
user, err := h.getOrCreateUser(chatID)
|
defer h.answerCb(ctx, cbID)
|
||||||
if err != nil {
|
loc := h.loadLocation(user.Timezone)
|
||||||
return
|
|
||||||
}
|
|
||||||
loc := loadLocation(user.Timezone)
|
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
cy, cm := now.Year(), int(now.Month())
|
cy, cm := now.Year(), int(now.Month())
|
||||||
switch user.Calendar {
|
switch user.Calendar {
|
||||||
case "jalali":
|
case "jalali":
|
||||||
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
|
cy, cm, _ = domain.GregorianToJalali(cy, cm, now.Day())
|
||||||
case "hijri":
|
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)
|
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 *domain.User) {
|
||||||
func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *db.User) {
|
months := domain.GregMonthNames
|
||||||
months := gregMonthNames
|
|
||||||
switch user.Calendar {
|
switch user.Calendar {
|
||||||
case "jalali":
|
case "jalali":
|
||||||
months = jalaliMonthNames
|
months = domain.JalaliMonthNames
|
||||||
case "hijri":
|
case "hijri":
|
||||||
months = hijriMonthNames
|
months = domain.HijriMonthNames
|
||||||
}
|
}
|
||||||
|
|
||||||
kbRows := [][]models.InlineKeyboardButton{
|
kbRows := [][]models.InlineKeyboardButton{
|
||||||
// Year navigation
|
|
||||||
{
|
{
|
||||||
{Text: "<", CallbackData: fmt.Sprintf("exp_year_prev_%d", year)},
|
{Text: "<", CallbackData: fmt.Sprintf("exp_year_prev_%d", year)},
|
||||||
{Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("exp_show_years_%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 {
|
for i := 0; i < 12; i += 4 {
|
||||||
row := []models.InlineKeyboardButton{}
|
row := []models.InlineKeyboardButton{}
|
||||||
for j := i; j < i+4 && j < 12; j++ {
|
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}
|
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
|
||||||
if msgID == 0 {
|
h.sendOrEdit(ctx, chatID, msgID, "Select month to export:", &kb)
|
||||||
h.sendWithKB(ctx, chatID, "Select month to export:", kb)
|
|
||||||
} else {
|
|
||||||
h.editText(ctx, chatID, msgID, "Select month to export:", &kb)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// editExportYearPicker shows a 12-year grid for year selection in the export flow.
|
func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
|
||||||
// refY is the year to return to via the Back button.
|
defer h.answerCb(ctx, cbID)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.handleExportNav(ctx, chatID, msgID, user, data) {
|
if h.handleExportNav(ctx, chatID, msgID, user, data) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.handleExportDo(ctx, chatID, msgID, user, data)
|
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 *domain.User, data string) bool {
|
||||||
func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *db.User, data string) bool {
|
|
||||||
var y int
|
var y int
|
||||||
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 {
|
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 {
|
||||||
h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user)
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleExportDo triggers the actual Excel export for a selected month.
|
func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *domain.User, data string) {
|
||||||
func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *db.User, data string) {
|
|
||||||
var y, m int
|
var y, m int
|
||||||
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 {
|
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
last, err := h.DB.GetLastEvent(user.ID)
|
last, err := h.Repo.GetLastEvent(user.ID)
|
||||||
if err == nil && last != nil && last.EventType == "in" {
|
if err == nil && last != nil && last.EventType == "in" {
|
||||||
backKB := models.InlineKeyboardMarkup{
|
backKB := models.InlineKeyboardMarkup{
|
||||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||||
@@ -514,21 +479,20 @@ func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, u
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
|
startStr, endStr := domain.MonthGregorianRange(user.Calendar, y, m)
|
||||||
start, err := time.Parse(DateLayout, startStr)
|
start, err := time.Parse(domain.DateLayout, startStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.sendText(ctx, chatID, "Error processing date")
|
h.sendText(ctx, chatID, "Error processing date")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
end, err := time.Parse(DateLayout, endStr)
|
end, err := time.Parse(domain.DateLayout, endStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.sendText(ctx, chatID, "Error processing date")
|
h.sendText(ctx, chatID, "Error processing date")
|
||||||
return
|
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)
|
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 {
|
if err != nil {
|
||||||
slog.Error("generate report", "error", err)
|
slog.Error("generate report", "error", err)
|
||||||
backKB := models.InlineKeyboardMarkup{
|
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)
|
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,
|
ChatID: chatID,
|
||||||
Document: &models.InputFileUpload{
|
Document: &models.InputFileUpload{
|
||||||
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
|
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)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,37 +6,58 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
|
|
||||||
|
"worktimeBot/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LeagueSortOption represents a valid league sort column.
|
// -- Command --
|
||||||
type LeagueSortOption string
|
|
||||||
|
|
||||||
const (
|
func (h *Handler) handleLeague(ctx context.Context, msg *models.Message, user *domain.User) {
|
||||||
LeagueSortXP LeagueSortOption = "xp"
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
||||||
LeagueSortLevel LeagueSortOption = "level"
|
if err != nil {
|
||||||
LeagueSortStreak LeagueSortOption = "streak"
|
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
|
||||||
LeagueSortHours LeagueSortOption = "hours"
|
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).
|
// -- Callback --
|
||||||
func (h *Handler) buildLeagueText(orderBy LeagueSortOption) string {
|
|
||||||
entries, err := h.DB.GetLeagueRankings(string(orderBy))
|
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 {
|
if err != nil {
|
||||||
return "League rankings unavailable."
|
return "League rankings unavailable."
|
||||||
}
|
}
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return "No participants in the league yet.\nEnable League participation in Settings to join!"
|
return "No participants in the league yet.\nEnable League participation in Settings to join!"
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
orderLabel := map[string]string{
|
orderLabel := map[string]string{"xp": "XP", "level": "Level", "streak": "Streak", "hours": "Hours"}
|
||||||
"xp": "XP",
|
b.WriteString(fmt.Sprintf("WorkTime League - %s\n\n", orderLabel[string(orderBy)]))
|
||||||
"level": "Level",
|
|
||||||
"streak": "Streak",
|
|
||||||
"hours": "Hours",
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf("WorkTime League — %s\n\n", orderLabel[string(orderBy)]))
|
|
||||||
|
|
||||||
for i, e := range entries {
|
for i, e := range entries {
|
||||||
rank := i + 1
|
rank := i + 1
|
||||||
name := e.Username
|
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("%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(" 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)))
|
b.WriteString(fmt.Sprintf("\n%d participant(s)\n", len(entries)))
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildLeagueRankText returns the user's personal rank in the league.
|
|
||||||
func (h *Handler) buildLeagueRankText(userID int64) string {
|
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 {
|
if err != nil || len(entries) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, e := range entries {
|
for i, e := range entries {
|
||||||
if e.UserID == userID {
|
if e.UserID == userID {
|
||||||
return fmt.Sprintf("League rank: #%d of %d", i+1, len(entries))
|
return fmt.Sprintf("League rank: #%d of %d", i+1, len(entries))
|
||||||
@@ -67,61 +84,14 @@ func (h *Handler) buildLeagueRankText(userID int64) string {
|
|||||||
return ""
|
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 {
|
func (h *Handler) buildLeagueKeyboard(currentOrder string) models.InlineKeyboardMarkup {
|
||||||
type sortBtn struct {
|
type sortBtn struct{ label, data string }
|
||||||
label string
|
|
||||||
data string
|
|
||||||
}
|
|
||||||
orders := []sortBtn{
|
orders := []sortBtn{
|
||||||
{"XP", "league_xp"},
|
{"XP", "league_xp"},
|
||||||
{"Level", "league_level"},
|
{"Level", "league_level"},
|
||||||
{"Streak", "league_streak"},
|
{"Streak", "league_streak"},
|
||||||
{"Hours", "league_hours"},
|
{"Hours", "league_hours"},
|
||||||
}
|
}
|
||||||
|
|
||||||
rows := [][]models.InlineKeyboardButton{}
|
rows := [][]models.InlineKeyboardButton{}
|
||||||
for i := 0; i < len(orders); i += 2 {
|
for i := 0; i < len(orders); i += 2 {
|
||||||
row := []models.InlineKeyboardButton{}
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -8,21 +8,15 @@ import (
|
|||||||
|
|
||||||
"github.com/go-telegram/bot/models"
|
"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) handleSummary(ctx context.Context, msg *models.Message, user *domain.User) {
|
||||||
func (h *Handler) handleSummaryMsg(ctx context.Context, msg *models.Message) {
|
loc := h.loadLocation(user.Timezone)
|
||||||
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)
|
now := time.Now().In(loc)
|
||||||
|
|
||||||
args := strings.Fields(msg.Text)
|
args := strings.Fields(msg.Text)
|
||||||
if len(args) > 0 && args[0][0] == '/' {
|
if len(args) >= 1 && args[0][0] == '/' {
|
||||||
args = args[1:]
|
args = args[1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,25 +30,24 @@ func (h *Handler) handleSummaryMsg(ctx context.Context, msg *models.Message) {
|
|||||||
calY, calM = now.Year(), int(now.Month())
|
calY, calM = now.Year(), int(now.Month())
|
||||||
switch user.Calendar {
|
switch user.Calendar {
|
||||||
case "jalali":
|
case "jalali":
|
||||||
calY, calM, _ = gregorianToJalali(calY, calM, now.Day())
|
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
|
||||||
case "hijri":
|
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)
|
summary := h.buildMonthlySummary(user, calY, calM, startStr, endStr, loc)
|
||||||
h.sendText(ctx, msg.Chat.ID, summary)
|
h.sendText(ctx, msg.Chat.ID, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMonthlySummary returns a formatted summary for a calendar month.
|
func (h *Handler) buildMonthlySummary(user *domain.User, calY, calM int, startStr, endStr string, loc *time.Location) string {
|
||||||
func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, endStr string, loc *time.Location) string {
|
days, err := h.Repo.GetUserDaysInRange(user.ID, startStr, endStr)
|
||||||
days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "Error loading days"
|
return "Error loading days"
|
||||||
}
|
}
|
||||||
|
|
||||||
title := formatMonthTitle(user.Calendar, calY, calM)
|
title := domain.FormatMonthTitle(user.Calendar, calY, calM)
|
||||||
|
|
||||||
totalWork := int64(0)
|
totalWork := int64(0)
|
||||||
totalBreak := int64(0)
|
totalBreak := int64(0)
|
||||||
@@ -63,13 +56,13 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
|
|||||||
dayDetails := []string{}
|
dayDetails := []string{}
|
||||||
|
|
||||||
for _, day := range days {
|
for _, day := range days {
|
||||||
dayLabel := formatDateForCalendar(day.Date, user.Calendar)
|
dayLabel := domain.FormatDateForCalendar(day.Date, user.Calendar)
|
||||||
|
|
||||||
if day.IsDayOff {
|
if day.IsDayOff {
|
||||||
dayOffCount++
|
dayOffCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
events, err := h.DB.EventsForDayByDayID(day.ID)
|
events, err := h.Repo.EventsForDayByDayID(day.ID)
|
||||||
if err != nil || len(events) == 0 {
|
if err != nil || len(events) == 0 {
|
||||||
if day.IsDayOff {
|
if day.IsDayOff {
|
||||||
dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel))
|
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
|
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()
|
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()
|
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 totals.TotalSeconds == 0 {
|
||||||
if day.IsDayOff {
|
if day.IsDayOff {
|
||||||
dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel))
|
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)))
|
b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak)))
|
||||||
}
|
}
|
||||||
|
|
||||||
st, _ := h.DB.GetOrCreateSettings(user.ID)
|
st, _ := h.Repo.GetOrCreateSettings(user.ID)
|
||||||
if st.HourlyRate > 0 {
|
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)",
|
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 {
|
} else {
|
||||||
b.WriteString("\nSalary: No rate configured — use /setrate <amount>")
|
b.WriteString("\nSalary: No rate configured. Use /setrate <amount>")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n\nDetails:\n")
|
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