fix: resolve go vet error and dayStart logic in ComputeDailyTotals

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

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

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