refactor: break up large functions, add RPG/league/salary/burnout, fix bugs, add tests
- Refactor GenerateMonthlyReport (198→70), HandleCallback (128→85), handleHistoryCallback (131→38), handleExportCallback (100→25), checkAchievements (86→25) into extracted helper functions - Add RPG system (XP, levels, streak, achievements, burnout) - Add WorkTime League leaderboard - Add salary estimation with configurable currency/rate - Add input sanitization (SanitizeNote, SanitizeDisplayName) - Add settings select-style pickers for toggles (report, RPG, league) - Add break threshold inline picker UI - Add event notes with pending state and /note command - Add multi-calendar support (Jalali, Hijri) - Add Excel export with theme picker - Fix: getTodayBreakThreshold uses user's timezone (was UTC) - Fix: acknowledgeCallback passes real callback ID - Fix: currency symbol safety with currencySymbol() helper - Fix: count work hours in summary on day-off days - Fix: unsilence all error returns from SQL Exec/Bot API/migrations - Remove stale db/schema.sql and unreferenced computeWeekTotals - Extract constants: DateLayout, TimeLayout, secondsPerHour, etc. - Add 12 new tests (XP, salary, sanitize, currency, dateutil) - Remove duplicate package doc comment in totals.go
This commit is contained in:
@@ -101,7 +101,12 @@ func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
for _, u := range users {
|
||||
if !u.ReportEnabled || !u.IsActive {
|
||||
if !u.IsActive {
|
||||
continue
|
||||
}
|
||||
|
||||
st, err := store.GetOrCreateSettings(u.ID)
|
||||
if err != nil || !st.ReportEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -114,11 +119,11 @@ func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) {
|
||||
today := localNow.Format("2006-01-02")
|
||||
currentHHMM := localNow.Format("15:04")
|
||||
|
||||
if currentHHMM != u.ReportTime {
|
||||
if currentHHMM != st.ReportTime {
|
||||
continue
|
||||
}
|
||||
|
||||
if u.LastReportDate != nil && *u.LastReportDate == today {
|
||||
if st.LastReportDate == today {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS work_types (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO work_types (id, name) VALUES (1, 'onsite');
|
||||
INSERT OR IGNORE INTO work_types (id, name) VALUES (2, 'remote');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL UNIQUE,
|
||||
timezone TEXT NOT NULL DEFAULT 'Asia/Tehran',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
report_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
report_time TEXT NOT NULL DEFAULT '23:00',
|
||||
last_report_date TEXT DEFAULT NULL,
|
||||
export_accent TEXT NOT NULL DEFAULT 'ocean'
|
||||
CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')),
|
||||
calendar TEXT NOT NULL DEFAULT 'gregorian'
|
||||
CHECK (calendar IN ('gregorian', 'jalali', 'hijri')),
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS days (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
date TEXT NOT NULL CHECK (date = strftime('%Y-%m-%d', date)),
|
||||
is_day_off INTEGER NOT NULL DEFAULT 0,
|
||||
day_off_reason TEXT NOT NULL DEFAULT '',
|
||||
current_work_type_id INTEGER NOT NULL DEFAULT 1 REFERENCES work_types(id),
|
||||
min_break_threshold INTEGER NOT NULL DEFAULT 300,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE(user_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
day_id INTEGER NOT NULL REFERENCES days(id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('in', 'out')),
|
||||
work_type_id INTEGER REFERENCES work_types(id),
|
||||
occurred_at INTEGER NOT NULL CHECK (occurred_at > 0),
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user_day ON events(user_id, day_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user_occurred ON events(user_id, occurred_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_days_user_date ON days(user_id, date);
|
||||
299
internal/bot/burnout.go
Normal file
299
internal/bot/burnout.go
Normal file
@@ -0,0 +1,299 @@
|
||||
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 recentAvg > olderAvg*1.2 {
|
||||
return 15, fmt.Sprintf("+%.0f%%", math.Round((recentAvg-olderAvg)/math.Max(olderAvg, 1)*100))
|
||||
}
|
||||
if olderAvg > recentAvg*1.2 {
|
||||
return 0, fmt.Sprintf("-%.0f%%", math.Round((olderAvg-recentAvg)/math.Max(olderAvg, 1)*100))
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
@@ -65,123 +65,138 @@ func (h *Handler) handleHistoryCallback(ctx context.Context, chatID int64, msgID
|
||||
return
|
||||
}
|
||||
|
||||
var y, m int
|
||||
var date string
|
||||
var eid int64
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to previous month
|
||||
// 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
|
||||
return true
|
||||
}
|
||||
// Navigate to next month
|
||||
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 {
|
||||
h.editCalendar(ctx, chatID, msgID, y, m)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Navigate to previous year
|
||||
if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 {
|
||||
h.editCalendar(ctx, chatID, msgID, y, m)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Navigate to next year
|
||||
if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 {
|
||||
h.editCalendar(ctx, chatID, msgID, y, m)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Show year picker from calendar
|
||||
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
|
||||
return true
|
||||
}
|
||||
// Navigate year block in picker
|
||||
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
|
||||
return true
|
||||
}
|
||||
// Select year from picker
|
||||
if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 {
|
||||
h.editCalendar(ctx, chatID, msgID, y, 1)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Back from year picker to calendar
|
||||
if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 {
|
||||
h.editCalendar(ctx, chatID, msgID, y, m)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Open a specific day
|
||||
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
|
||||
return true
|
||||
}
|
||||
// Back to day view from event editing
|
||||
var eid int64
|
||||
if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 {
|
||||
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Set event work type
|
||||
var wtid int64
|
||||
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
|
||||
return true
|
||||
}
|
||||
// Show work type picker for an event
|
||||
if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 {
|
||||
h.editEventType(ctx, chatID, msgID, user, eid, loc)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Delete event confirmation prompt
|
||||
if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 {
|
||||
h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Confirm event deletion
|
||||
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
|
||||
h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Show hour picker for event time
|
||||
if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 {
|
||||
h.editEventTime(ctx, chatID, msgID, user, eid, loc)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Set event time (hour+minute)
|
||||
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)
|
||||
return
|
||||
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc, callbackID)
|
||||
return true
|
||||
}
|
||||
// Show minute picker (hour already chosen)
|
||||
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
|
||||
h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Set note for an event — prompt user for text
|
||||
if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 {
|
||||
h.promptNote(ctx, chatID, msgID, user, eid, loc)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Cancel note prompt — return to day view
|
||||
if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 {
|
||||
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Add IN event — show hour picker
|
||||
if strings.HasPrefix(data, "addin_") {
|
||||
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)
|
||||
return
|
||||
}
|
||||
// Add OUT event — show hour picker
|
||||
if strings.HasPrefix(data, "addout_") {
|
||||
case strings.HasPrefix(data, "addout_"):
|
||||
h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc)
|
||||
return
|
||||
}
|
||||
// Add event hour/minute callbacks
|
||||
if strings.HasPrefix(data, "addtm_") {
|
||||
case strings.HasPrefix(data, "addtm_"):
|
||||
h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc)
|
||||
return
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sendCalendar sends a calendar view as a new message.
|
||||
@@ -245,7 +260,7 @@ func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, yea
|
||||
cm := buildCalendarMonth(cal, year, month)
|
||||
|
||||
text := cm.title
|
||||
todayKey := now.Format("2006-01-02")
|
||||
todayKey := now.Format(DateLayout)
|
||||
|
||||
hasEvent := make(map[string]bool)
|
||||
startDate, endDate := monthGregorianRange(cal, year, month)
|
||||
@@ -365,7 +380,7 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user
|
||||
text += "\n\nEvents:"
|
||||
kbRows := [][]models.InlineKeyboardButton{}
|
||||
for _, e := range events {
|
||||
t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04")
|
||||
t := time.Unix(e.OccurredAt, 0).In(loc).Format(TimeLayout)
|
||||
label := "IN"
|
||||
if e.EventType == "out" {
|
||||
label = "OUT"
|
||||
@@ -435,7 +450,7 @@ func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int,
|
||||
return
|
||||
}
|
||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
||||
h.sendDayView(ctx, chatID, msgID, user, t.Format("2006-01-02"), loc, false)
|
||||
h.sendDayView(ctx, chatID, msgID, user, t.Format(DateLayout), loc, false)
|
||||
}
|
||||
|
||||
// buildHourPickerKeyboard creates the 24-hour grid for time selection.
|
||||
@@ -488,14 +503,14 @@ func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int,
|
||||
}
|
||||
|
||||
// editEventTimeSet applies a new time to an event, checking for overlaps.
|
||||
func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location) {
|
||||
func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location, callbackID string) {
|
||||
event, err := h.getEventByID(user.ID, eventID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
||||
newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc)
|
||||
date := t.Format("2006-01-02")
|
||||
date := t.Format(DateLayout)
|
||||
|
||||
// Reject if the new time collides with another event's timestamp
|
||||
events, err := h.DB.EventsForDayByDate(user.ID, date)
|
||||
@@ -506,7 +521,7 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int,
|
||||
}
|
||||
eTime := time.Unix(e.OccurredAt, 0)
|
||||
if newTime.Unix() == eTime.Unix() {
|
||||
h.acknowledgeCallback(ctx, chatID, msgID)
|
||||
h.acknowledgeCallback(ctx, callbackID)
|
||||
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
||||
return
|
||||
}
|
||||
@@ -552,8 +567,8 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int,
|
||||
}
|
||||
|
||||
// acknowledgeCallback sends an empty acknowledgement for a callback query.
|
||||
func (h *Handler) acknowledgeCallback(ctx context.Context, chatID int64, msgID int) {
|
||||
h.answerCb(ctx, fmt.Sprintf("cb_%d_%d", chatID, msgID))
|
||||
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.
|
||||
@@ -562,7 +577,7 @@ func (h *Handler) promptNote(ctx context.Context, chatID int64, msgID int, user
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
t := time.Unix(event.OccurredAt, 0).In(loc).Format("15:04")
|
||||
t := time.Unix(event.OccurredAt, 0).In(loc).Format(TimeLayout)
|
||||
|
||||
h.mu.Lock()
|
||||
h.pendingNotes[chatID] = &pendingNote{eventID: eventID, createdAt: time.Now()}
|
||||
@@ -596,7 +611,7 @@ func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID in
|
||||
return
|
||||
}
|
||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
||||
date := t.Format("2006-01-02")
|
||||
date := t.Format(DateLayout)
|
||||
|
||||
_, err = h.DB.DB().Exec("DELETE FROM events WHERE id=?", eventID)
|
||||
if err != nil {
|
||||
@@ -630,7 +645,7 @@ func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, us
|
||||
return
|
||||
}
|
||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
||||
h.sendDayView(ctx, chatID, msgID, user, t.Format("2006-01-02"), loc, false)
|
||||
h.sendDayView(ctx, chatID, msgID, user, t.Format(DateLayout), loc, false)
|
||||
}
|
||||
|
||||
// getEventByID fetches a single event by ID, scoped to the user.
|
||||
|
||||
@@ -24,7 +24,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
lastTime := time.Unix(last.OccurredAt, 0).In(loc)
|
||||
if lastTime.Format("2006-01-02") == now.Format("2006-01-02") {
|
||||
if lastTime.Format(DateLayout) == now.Format(DateLayout) {
|
||||
return "", errors.New("already clocked in")
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,12 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
|
||||
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("15:04")), nil
|
||||
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 {
|
||||
@@ -60,7 +61,30 @@ func (h *Handler) clockOut(chatID int64) (string, error) {
|
||||
if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("clocked out at %s", now.Format("15:04")), nil
|
||||
text := fmt.Sprintf("clocked out at %s", now.Format(TimeLayout))
|
||||
|
||||
// Award XP and update streaks if RPG enabled
|
||||
st, _ := h.DB.GetOrCreateSettings(user.ID)
|
||||
if st.RPGEnabled {
|
||||
sessionWork := now.Unix() - last.OccurredAt
|
||||
today := now.Format(DateLayout)
|
||||
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
|
||||
|
||||
streak, _ := h.updateStreak(user.ID, today, true)
|
||||
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(user.ID, sessionWork, streak, today)
|
||||
stats, _ := h.DB.GetOrCreateRPGStats(user.ID)
|
||||
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend)
|
||||
|
||||
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.
|
||||
@@ -100,11 +124,15 @@ func (h *Handler) toggleReport(chatID int64) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
user.ReportEnabled = !user.ReportEnabled
|
||||
if err := h.DB.UpdateUser(user); err != nil {
|
||||
st, err := h.DB.GetOrCreateSettings(user.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if user.ReportEnabled {
|
||||
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
|
||||
|
||||
@@ -28,6 +28,152 @@ var themes = map[string]themeColors{
|
||||
"catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"},
|
||||
}
|
||||
|
||||
type reportStyles struct {
|
||||
title int
|
||||
header int
|
||||
data int
|
||||
total int
|
||||
dayOff int
|
||||
}
|
||||
|
||||
func newReportStyles(f *excelize.File, theme themeColors) reportStyles {
|
||||
return reportStyles{
|
||||
title: newStyle(f, &excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
}),
|
||||
header: newStyle(f, &excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: "FFFFFF", Style: 1},
|
||||
{Type: "right", Color: "FFFFFF", Style: 1},
|
||||
{Type: "top", Color: "FFFFFF", Style: 1},
|
||||
{Type: "bottom", Color: "FFFFFF", Style: 1},
|
||||
},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
}),
|
||||
data: newStyle(f, &excelize.Style{
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: theme.border, Style: 1},
|
||||
{Type: "right", Color: theme.border, Style: 1},
|
||||
{Type: "top", Color: theme.border, Style: 1},
|
||||
{Type: "bottom", Color: theme.border, Style: 1},
|
||||
},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
}),
|
||||
total: newStyle(f, &excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Size: 11},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: theme.border, Style: 1},
|
||||
{Type: "right", Color: theme.border, Style: 1},
|
||||
{Type: "top", Color: theme.border, Style: 1},
|
||||
{Type: "bottom", Color: theme.border, Style: 2},
|
||||
},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
}),
|
||||
dayOff: newStyle(f, &excelize.Style{
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: theme.border, Style: 1},
|
||||
{Type: "right", Color: theme.border, Style: 1},
|
||||
{Type: "top", Color: theme.border, Style: 1},
|
||||
{Type: "bottom", Color: theme.border, Style: 1},
|
||||
},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func newStyle(f *excelize.File, s *excelize.Style) int {
|
||||
id, _ := f.NewStyle(s)
|
||||
return id
|
||||
}
|
||||
|
||||
func writeReportHeader(f *excelize.File, sheet, title string, s reportStyles) {
|
||||
f.SetCellValue(sheet, "A1", title)
|
||||
f.MergeCell(sheet, "A1", "F1")
|
||||
f.SetCellStyle(sheet, "A1", "F1", s.title)
|
||||
f.SetRowHeight(sheet, 1, 30)
|
||||
|
||||
headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"}
|
||||
for i, h := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
|
||||
f.SetCellValue(sheet, cell, h)
|
||||
f.SetCellStyle(sheet, cell, cell, s.header)
|
||||
}
|
||||
f.SetRowHeight(sheet, 2, 22)
|
||||
|
||||
f.SetColWidth(sheet, "A", "A", 14)
|
||||
f.SetColWidth(sheet, "B", "B", 10)
|
||||
f.SetColWidth(sheet, "C", "C", 10)
|
||||
f.SetColWidth(sheet, "D", "D", 12)
|
||||
f.SetColWidth(sheet, "E", "E", 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) {
|
||||
cellDate, _ := excelize.CoordinatesToCellName(1, row)
|
||||
f.SetCellValue(sheet, cellDate, displayDate)
|
||||
f.SetCellStyle(sheet, cellDate, cellDate, s.data)
|
||||
|
||||
if len(events) > 0 {
|
||||
cellIn, _ := excelize.CoordinatesToCellName(2, row)
|
||||
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format(TimeLayout))
|
||||
f.SetCellStyle(sheet, cellIn, cellIn, s.data)
|
||||
}
|
||||
if len(events) > 0 {
|
||||
lastEvent := events[len(events)-1]
|
||||
if lastEvent.EventType == "out" {
|
||||
cellOut, _ := excelize.CoordinatesToCellName(3, row)
|
||||
f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format(TimeLayout))
|
||||
f.SetCellStyle(sheet, cellOut, cellOut, s.data)
|
||||
}
|
||||
}
|
||||
|
||||
cellType, _ := excelize.CoordinatesToCellName(4, row)
|
||||
f.SetCellValue(sheet, cellType, typeLabel)
|
||||
f.SetCellStyle(sheet, cellType, cellType, s.data)
|
||||
|
||||
if len(events) > 0 && day != nil {
|
||||
var d time.Time
|
||||
d, _ = time.Parse(DateLayout, day.Date)
|
||||
dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix()
|
||||
dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix()
|
||||
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
|
||||
workSec = totals.TotalSeconds
|
||||
breakSec = totals.BreakSeconds
|
||||
}
|
||||
|
||||
cellWork, _ := excelize.CoordinatesToCellName(5, row)
|
||||
f.SetCellValue(sheet, cellWork, fmtHHMM(workSec))
|
||||
f.SetCellStyle(sheet, cellWork, cellWork, s.data)
|
||||
|
||||
cellBreak, _ := excelize.CoordinatesToCellName(6, row)
|
||||
f.SetCellValue(sheet, cellBreak, fmtHHMM(breakSec))
|
||||
f.SetCellStyle(sheet, cellBreak, cellBreak, s.data)
|
||||
|
||||
if hasDayOff {
|
||||
cellA, _ := excelize.CoordinatesToCellName(1, row)
|
||||
cellF, _ := excelize.CoordinatesToCellName(6, row)
|
||||
f.SetCellStyle(sheet, cellA, cellF, s.dayOff)
|
||||
}
|
||||
|
||||
return workSec, breakSec
|
||||
}
|
||||
|
||||
func writeReportTotalRow(f *excelize.File, sheet string, row int, totalWork, totalBreak int64, s reportStyles) {
|
||||
row++
|
||||
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
|
||||
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), s.total)
|
||||
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork))
|
||||
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), s.total)
|
||||
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak))
|
||||
f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), s.total)
|
||||
}
|
||||
|
||||
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) {
|
||||
theme, ok := themes[accent]
|
||||
if !ok {
|
||||
@@ -45,79 +191,15 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
||||
f.SetActiveSheet(index)
|
||||
f.DeleteSheet("Sheet1")
|
||||
|
||||
titleStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
})
|
||||
headerStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: "FFFFFF", Style: 1},
|
||||
{Type: "right", Color: "FFFFFF", Style: 1},
|
||||
{Type: "top", Color: "FFFFFF", Style: 1},
|
||||
{Type: "bottom", Color: "FFFFFF", Style: 1},
|
||||
},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
})
|
||||
dataStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: theme.border, Style: 1},
|
||||
{Type: "right", Color: theme.border, Style: 1},
|
||||
{Type: "top", Color: theme.border, Style: 1},
|
||||
{Type: "bottom", Color: theme.border, Style: 1},
|
||||
},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
})
|
||||
totalStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Size: 11},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: theme.border, Style: 1},
|
||||
{Type: "right", Color: theme.border, Style: 1},
|
||||
{Type: "top", Color: theme.border, Style: 1},
|
||||
{Type: "bottom", Color: theme.border, Style: 2},
|
||||
},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
})
|
||||
dayOffStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: theme.border, Style: 1},
|
||||
{Type: "right", Color: theme.border, Style: 1},
|
||||
{Type: "top", Color: theme.border, Style: 1},
|
||||
{Type: "bottom", Color: theme.border, Style: 1},
|
||||
},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
})
|
||||
styles := newReportStyles(f, theme)
|
||||
|
||||
monthName := formatMonthTitle(calendar, calYear, calMonth)
|
||||
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
|
||||
f.MergeCell(sheet, "A1", "F1")
|
||||
f.SetCellStyle(sheet, "A1", "F1", titleStyle)
|
||||
f.SetRowHeight(sheet, 1, 30)
|
||||
|
||||
headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"}
|
||||
for i, h := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
|
||||
f.SetCellValue(sheet, cell, h)
|
||||
f.SetCellStyle(sheet, cell, cell, headerStyle)
|
||||
}
|
||||
f.SetRowHeight(sheet, 2, 22)
|
||||
|
||||
f.SetColWidth(sheet, "A", "A", 14)
|
||||
f.SetColWidth(sheet, "B", "B", 10)
|
||||
f.SetColWidth(sheet, "C", "C", 10)
|
||||
f.SetColWidth(sheet, "D", "D", 12)
|
||||
f.SetColWidth(sheet, "E", "E", 10)
|
||||
f.SetColWidth(sheet, "F", "F", 10)
|
||||
writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles)
|
||||
|
||||
loc := loadLocation(timezone)
|
||||
|
||||
row := 3
|
||||
|
||||
userDays, err := store.GetUserDaysInRange(userID, start.Format("2006-01-02"), end.Format("2006-01-02"))
|
||||
userDays, err := store.GetUserDaysInRange(userID, start.Format(DateLayout), end.Format(DateLayout))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -130,10 +212,9 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
||||
var totalWork, totalBreak int64
|
||||
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
dateStr := d.Format("2006-01-02")
|
||||
dateStr := d.Format(DateLayout)
|
||||
displayDate := formatDateForCalendar(dateStr, calendar)
|
||||
|
||||
// Determine if this day exists, its events, and day-off status.
|
||||
var (
|
||||
day *db.Day
|
||||
events []db.Event
|
||||
@@ -148,26 +229,6 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
||||
hasDayOff = day.IsDayOff
|
||||
}
|
||||
|
||||
// Write the date cell for every day.
|
||||
cellDate, _ := excelize.CoordinatesToCellName(1, row)
|
||||
f.SetCellValue(sheet, cellDate, displayDate)
|
||||
f.SetCellStyle(sheet, cellDate, cellDate, dataStyle)
|
||||
|
||||
if len(events) > 0 {
|
||||
cellIn, _ := excelize.CoordinatesToCellName(2, row)
|
||||
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format("15:04"))
|
||||
f.SetCellStyle(sheet, cellIn, cellIn, dataStyle)
|
||||
}
|
||||
if len(events) > 0 {
|
||||
lastEvent := events[len(events)-1]
|
||||
if lastEvent.EventType == "out" {
|
||||
cellOut, _ := excelize.CoordinatesToCellName(3, row)
|
||||
f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format("15:04"))
|
||||
f.SetCellStyle(sheet, cellOut, cellOut, dataStyle)
|
||||
}
|
||||
}
|
||||
|
||||
// Type column: day-off, work-type label, or empty.
|
||||
var typeLabel string
|
||||
if hasDayOff {
|
||||
if day.DayOffReason != "" {
|
||||
@@ -178,47 +239,14 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
||||
} else if len(events) > 0 {
|
||||
typeLabel = workTypeLabel(store, day, events)
|
||||
}
|
||||
cellType, _ := excelize.CoordinatesToCellName(4, row)
|
||||
f.SetCellValue(sheet, cellType, typeLabel)
|
||||
f.SetCellStyle(sheet, cellType, cellType, dataStyle)
|
||||
|
||||
// Work and Break columns — compute totals only when there are events.
|
||||
var workSec, breakSec int64
|
||||
if len(events) > 0 && day != nil {
|
||||
dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix()
|
||||
dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix()
|
||||
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
|
||||
workSec = totals.TotalSeconds
|
||||
breakSec = totals.BreakSeconds
|
||||
totalWork += workSec
|
||||
totalBreak += breakSec
|
||||
}
|
||||
|
||||
cellWork, _ := excelize.CoordinatesToCellName(5, row)
|
||||
f.SetCellValue(sheet, cellWork, fmtHHMM(workSec))
|
||||
f.SetCellStyle(sheet, cellWork, cellWork, dataStyle)
|
||||
|
||||
cellBreak, _ := excelize.CoordinatesToCellName(6, row)
|
||||
f.SetCellValue(sheet, cellBreak, fmtHHMM(breakSec))
|
||||
f.SetCellStyle(sheet, cellBreak, cellBreak, dataStyle)
|
||||
|
||||
if hasDayOff {
|
||||
cellA, _ := excelize.CoordinatesToCellName(1, row)
|
||||
cellF, _ := excelize.CoordinatesToCellName(6, row)
|
||||
f.SetCellStyle(sheet, cellA, cellF, dayOffStyle)
|
||||
}
|
||||
|
||||
ws, bs := writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles)
|
||||
totalWork += ws
|
||||
totalBreak += bs
|
||||
row++
|
||||
}
|
||||
|
||||
row++
|
||||
|
||||
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
|
||||
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), totalStyle)
|
||||
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork))
|
||||
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), totalStyle)
|
||||
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak))
|
||||
f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), totalStyle)
|
||||
writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles)
|
||||
|
||||
buf, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
@@ -253,16 +281,16 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
|
||||
}
|
||||
|
||||
func fmtHHMM(seconds int64) string {
|
||||
hours := seconds / 3600
|
||||
mins := (seconds % 3600) / 60
|
||||
hours := seconds / secondsPerHour
|
||||
mins := (seconds % secondsPerHour) / 60
|
||||
return fmt.Sprintf("%d:%02d", hours, mins)
|
||||
}
|
||||
|
||||
func fmtDDHHMM(seconds int64) string {
|
||||
days := seconds / 86400
|
||||
rem := seconds % 86400
|
||||
hours := rem / 3600
|
||||
mins := (rem % 3600) / 60
|
||||
days := seconds / secondsPerDay
|
||||
rem := seconds % secondsPerDay
|
||||
hours := rem / secondsPerHour
|
||||
mins := (rem % secondsPerHour) / 60
|
||||
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
|
||||
}
|
||||
|
||||
@@ -310,18 +338,20 @@ func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.U
|
||||
|
||||
loc := loadLocation(user.Timezone)
|
||||
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
|
||||
start, err := time.Parse("2006-01-02", startStr)
|
||||
start, err := time.Parse(DateLayout, startStr)
|
||||
if err != nil {
|
||||
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
||||
}
|
||||
end, err := time.Parse("2006-01-02", endStr)
|
||||
end, err := time.Parse(DateLayout, endStr)
|
||||
if err != nil {
|
||||
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
||||
end = startOfMonth.AddDate(0, 1, -1)
|
||||
}
|
||||
|
||||
st, _ := h.DB.GetOrCreateSettings(user.ID)
|
||||
accent := st.ExportAccent
|
||||
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
|
||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end)
|
||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, accent, user.Calendar, calYear, calMonth, start, end)
|
||||
if err != nil {
|
||||
slog.Error("generate report", "error", err)
|
||||
h.sendText(ctx, chatID, "Error generating report")
|
||||
@@ -434,94 +464,102 @@ func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID
|
||||
return
|
||||
}
|
||||
|
||||
var y, m int
|
||||
if h.handleExportNav(ctx, chatID, msgID, user, data) {
|
||||
return
|
||||
}
|
||||
h.handleExportDo(ctx, chatID, msgID, user, data)
|
||||
}
|
||||
|
||||
// Navigate to previous year
|
||||
// handleExportNav handles year/month navigation callbacks for the export flow.
|
||||
func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *db.User, data string) bool {
|
||||
var y int
|
||||
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 {
|
||||
h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Navigate to next year
|
||||
if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 {
|
||||
h.editExportMonthPicker(ctx, chatID, msgID, y+1, 0, user)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Show year picker from month picker
|
||||
if n, _ := fmt.Sscanf(data, "exp_show_years_%d", &y); n == 1 {
|
||||
h.editExportYearPicker(ctx, chatID, msgID, y, y)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Navigate year block in picker
|
||||
var refY int
|
||||
if n, _ := fmt.Sscanf(data, "exp_years_%d_%d", &y, &refY); n == 2 {
|
||||
h.editExportYearPicker(ctx, chatID, msgID, y+6, refY)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Select year from picker
|
||||
if n, _ := fmt.Sscanf(data, "exp_year_%d", &y); n == 1 {
|
||||
h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user)
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Back from year picker to month picker
|
||||
if n, _ := fmt.Sscanf(data, "exp_years_back_%d", &y); n == 1 {
|
||||
h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleExportDo triggers the actual Excel export for a selected month.
|
||||
func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *db.User, data string) {
|
||||
var y, m int
|
||||
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
// Export a specific month
|
||||
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 {
|
||||
last, err := h.DB.GetLastEvent(user.ID)
|
||||
if err == nil && last != nil && last.EventType == "in" {
|
||||
kb := models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
},
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, "You are currently clocked in. Please clock out first, then try again.", &kb)
|
||||
return
|
||||
}
|
||||
|
||||
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
|
||||
start, err := time.Parse("2006-01-02", startStr)
|
||||
if err != nil {
|
||||
h.sendText(ctx, chatID, "Error processing date")
|
||||
return
|
||||
}
|
||||
end, err := time.Parse("2006-01-02", endStr)
|
||||
if err != nil {
|
||||
h.sendText(ctx, chatID, "Error processing date")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
|
||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
|
||||
if err != nil {
|
||||
slog.Error("generate report", "error", err)
|
||||
kb := models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
},
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, "Error generating report", &kb)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("report generated", "bytes", len(data))
|
||||
kb := models.InlineKeyboardMarkup{
|
||||
last, err := h.DB.GetLastEvent(user.ID)
|
||||
if err == nil && last != nil && last.EventType == "in" {
|
||||
backKB := models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
},
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, "Report ready:", &kb)
|
||||
h.editText(ctx, chatID, msgID, "You are currently clocked in. Please clock out first, then try again.", &backKB)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{
|
||||
ChatID: chatID,
|
||||
Document: &models.InputFileUpload{
|
||||
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
|
||||
Data: bytes.NewReader(data),
|
||||
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
|
||||
start, err := time.Parse(DateLayout, startStr)
|
||||
if err != nil {
|
||||
h.sendText(ctx, chatID, "Error processing date")
|
||||
return
|
||||
}
|
||||
end, err := time.Parse(DateLayout, endStr)
|
||||
if err != nil {
|
||||
h.sendText(ctx, chatID, "Error processing date")
|
||||
return
|
||||
}
|
||||
|
||||
st, _ := h.DB.GetOrCreateSettings(user.ID)
|
||||
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
|
||||
reportData, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, st.ExportAccent, user.Calendar, y, m, start, end)
|
||||
if err != nil {
|
||||
slog.Error("generate report", "error", err)
|
||||
backKB := models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
},
|
||||
}); err != nil {
|
||||
slog.Error("send document", "error", err)
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, "Error generating report", &backKB)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("report generated", "bytes", len(reportData))
|
||||
backKB := models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
},
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, "Report ready:", &backKB)
|
||||
|
||||
if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{
|
||||
ChatID: chatID,
|
||||
Document: &models.InputFileUpload{
|
||||
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
|
||||
Data: bytes.NewReader(reportData),
|
||||
},
|
||||
}); err != nil {
|
||||
slog.Error("send document", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,21 @@ import (
|
||||
"worktimeBot/internal/db"
|
||||
)
|
||||
|
||||
const rateLimitInterval = 1 * time.Second
|
||||
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
|
||||
@@ -129,10 +143,10 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
|
||||
loc := loadLocation(user.Timezone)
|
||||
event, err := h.getEventByID(user.ID, pn.eventID)
|
||||
if err == nil {
|
||||
if err := h.DB.SetEventNote(pn.eventID, msg.Text); 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("2006-01-02")
|
||||
date := time.Unix(event.OccurredAt, 0).In(loc).Format(DateLayout)
|
||||
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
|
||||
}
|
||||
return
|
||||
@@ -183,6 +197,24 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
|
||||
h.handleSummaryMsg(ctx, msg)
|
||||
case "/setbreak":
|
||||
h.handleSetBreakMsg(ctx, msg)
|
||||
case "/rpg":
|
||||
h.handleActionMsg(ctx, msg, h.rpgStatus)
|
||||
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.handleLeagueSettingsMsg(ctx, msg)
|
||||
default:
|
||||
h.sendText(ctx, msg.Chat.ID, "Unknown command")
|
||||
}
|
||||
@@ -232,8 +264,6 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
|
||||
h.timezoneCallback(ctx, chatID, msgID, cb.ID)
|
||||
case "history":
|
||||
h.historyCallback(ctx, chatID, msgID, cb.ID)
|
||||
case "reporttoggle":
|
||||
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.toggleReport)
|
||||
case "reporttime":
|
||||
h.reportTimeCallback(ctx, chatID, msgID, cb.ID)
|
||||
case "breakthreshold":
|
||||
@@ -242,29 +272,75 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
|
||||
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 "currency":
|
||||
h.currencyCallback(ctx, chatID, msgID, cb.ID)
|
||||
case "setrate":
|
||||
h.rateCallback(ctx, chatID, msgID, cb.ID)
|
||||
default:
|
||||
// Delegate prefixed callbacks to the appropriate sub-handler
|
||||
if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" {
|
||||
h.selectWorkType(ctx, chatID, msgID, cb.ID, cb.Data[9:])
|
||||
return
|
||||
}
|
||||
if len(cb.Data) > 7 && cb.Data[:7] == "accent_" {
|
||||
h.selectAccent(ctx, chatID, msgID, cb.ID, cb.Data[7:])
|
||||
return
|
||||
}
|
||||
if len(cb.Data) > 8 && cb.Data[:8] == "caltype_" {
|
||||
h.selectCalendar(ctx, chatID, msgID, cb.ID, cb.Data[8:])
|
||||
return
|
||||
}
|
||||
if len(cb.Data) > 4 && cb.Data[:4] == "exp_" {
|
||||
h.handleExportCallback(ctx, chatID, msgID, cb.ID, cb.Data)
|
||||
return
|
||||
}
|
||||
if len(cb.Data) > 15 && cb.Data[:15] == "breakthreshold_" {
|
||||
h.selectBreakThreshold(ctx, chatID, msgID, cb.ID, cb.Data[15:])
|
||||
return
|
||||
}
|
||||
h.handleHistoryCallback(ctx, chatID, msgID, cb.ID, cb.Data)
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +359,7 @@ func (h *Handler) getUserToday(chatID int64) (*db.User, *db.Day, error) {
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
today := time.Now().In(loc).Format("2006-01-02")
|
||||
today := time.Now().In(loc).Format(DateLayout)
|
||||
day, err := h.DB.GetOrCreateDay(user.ID, today)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -313,16 +389,25 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
|
||||
/worktype - Select work type
|
||||
/dayoff - Toggle day off
|
||||
/report - Show today report
|
||||
/export [YYYY-MM] - Export monthly report (current month if omitted)
|
||||
/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 (current month if omitted)
|
||||
/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 your timezone (e.g. Asia/Tehran)
|
||||
/setbreak <minutes> - Set minimum break threshold in minutes
|
||||
/settimezone <name> - Set timezone (e.g. Asia/Tehran)
|
||||
/setbreak <minutes> - Set break threshold in minutes
|
||||
/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.`
|
||||
@@ -354,7 +439,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
|
||||
timeStr = parts[2]
|
||||
noteParts = parts[3:]
|
||||
} else {
|
||||
dateStr = time.Now().In(loc).Format("2006-01-02")
|
||||
dateStr = time.Now().In(loc).Format(DateLayout)
|
||||
timeStr = parts[1]
|
||||
noteParts = parts[2:]
|
||||
}
|
||||
@@ -367,7 +452,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
|
||||
|
||||
var targetEvent *db.Event
|
||||
for i := range events {
|
||||
t := time.Unix(events[i].OccurredAt, 0).In(loc).Format("15:04")
|
||||
t := time.Unix(events[i].OccurredAt, 0).In(loc).Format(TimeLayout)
|
||||
if t == timeStr {
|
||||
targetEvent = &events[i]
|
||||
break
|
||||
@@ -378,7 +463,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
|
||||
return
|
||||
}
|
||||
|
||||
note := strings.Join(noteParts, " ")
|
||||
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
|
||||
@@ -470,8 +555,15 @@ func mainKeyboard() models.InlineKeyboardMarkup {
|
||||
{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
@@ -489,8 +581,8 @@ func backKeyboard() models.InlineKeyboardMarkup {
|
||||
|
||||
// formatDuration formats seconds as a human-readable duration (e.g. "7h 30m").
|
||||
func formatDuration(seconds int64) string {
|
||||
hours := seconds / 3600
|
||||
mins := (seconds % 3600) / 60
|
||||
hours := seconds / secondsPerHour
|
||||
mins := (seconds % secondsPerHour) / 60
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, mins)
|
||||
}
|
||||
|
||||
157
internal/bot/league.go
Normal file
157
internal/bot/league.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// LeagueSortOption represents a valid league sort column.
|
||||
type LeagueSortOption string
|
||||
|
||||
const (
|
||||
LeagueSortXP LeagueSortOption = "xp"
|
||||
LeagueSortLevel LeagueSortOption = "level"
|
||||
LeagueSortStreak LeagueSortOption = "streak"
|
||||
LeagueSortHours LeagueSortOption = "hours"
|
||||
)
|
||||
|
||||
// buildLeagueText returns the formatted league rankings.
|
||||
func (h *Handler) buildLeagueText(orderBy LeagueSortOption) string {
|
||||
entries, err := h.DB.GetLeagueRankings(string(orderBy))
|
||||
if err != nil {
|
||||
return "League rankings unavailable."
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return "No participants in the league yet.\nEnable League participation in Settings to join!"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
orderLabel := map[string]string{
|
||||
"xp": "XP",
|
||||
"level": "Level",
|
||||
"streak": "Streak",
|
||||
"hours": "Total Hours",
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("WorkTime League — sorted by %s\n\n", orderLabel[string(orderBy)]))
|
||||
|
||||
// Determine column widths
|
||||
maxUserLen := 0
|
||||
for _, e := range entries {
|
||||
l := len(e.Username)
|
||||
if l > maxUserLen {
|
||||
maxUserLen = l
|
||||
}
|
||||
}
|
||||
if maxUserLen < 8 {
|
||||
maxUserLen = 8
|
||||
}
|
||||
if maxUserLen > 20 {
|
||||
maxUserLen = 20
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("%-3s %-*s %8s %6s %6s %12s", "#", maxUserLen, "Username", "XP", "Level", "Streak", "Hours")
|
||||
b.WriteString(header + "\n")
|
||||
b.WriteString(strings.Repeat("-", len(header)) + "\n")
|
||||
|
||||
for i, e := range entries {
|
||||
rank := i + 1
|
||||
name := e.Username
|
||||
if len(name) > maxUserLen {
|
||||
name = name[:maxUserLen]
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%-3d %-*s %8d %6d %6d %12.1f\n",
|
||||
rank, maxUserLen, name, e.XP, e.Level, e.Streak, e.TotalHours))
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("\n%d participant(s)\n", len(entries)))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildLeagueRankText returns the user's personal rank in the league.
|
||||
func (h *Handler) buildLeagueRankText(userID int64) string {
|
||||
entries, err := h.DB.GetLeagueRankings("xp")
|
||||
if err != nil || len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
for i, e := range entries {
|
||||
if e.UserID == userID {
|
||||
return fmt.Sprintf("League rank: #%d of %d", i+1, len(entries))
|
||||
}
|
||||
}
|
||||
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.
|
||||
func (h *Handler) buildLeagueKeyboard(currentOrder string) models.InlineKeyboardMarkup {
|
||||
orders := []struct {
|
||||
label string
|
||||
data string
|
||||
}{
|
||||
{"XP", "league_xp"},
|
||||
{"Level", "league_level"},
|
||||
{"Streak", "league_streak"},
|
||||
{"Hours", "league_hours"},
|
||||
}
|
||||
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
for _, o := range orders {
|
||||
label := o.label
|
||||
if o.data == "league_"+currentOrder {
|
||||
label = "> " + label
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: label, CallbackData: o.data},
|
||||
})
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
{Text: "Back to Menu", CallbackData: "back_menu"},
|
||||
})
|
||||
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func (h *Handler) buildStatusText(chatID int64) string {
|
||||
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("15:04")
|
||||
t := time.Unix(last.OccurredAt, 0).In(loc).Format(TimeLayout)
|
||||
text += fmt.Sprintf("\nStatus: working since %s", t)
|
||||
} else {
|
||||
text += "\nStatus: not working"
|
||||
@@ -49,14 +49,33 @@ func (h *Handler) buildStatusText(chatID int64) string {
|
||||
text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds))
|
||||
}
|
||||
|
||||
loc := loadLocation(user.Timezone)
|
||||
weekTotal := h.computeWeekTotals(user, loc)
|
||||
if weekTotal > 0 {
|
||||
text += fmt.Sprintf("\nWeek: %s", formatDuration(weekTotal))
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if day.MinBreakThreshold > 0 {
|
||||
text += fmt.Sprintf("\nBreak threshold: %dm", day.MinBreakThreshold/60)
|
||||
// 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(" | %s", rankText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text += "\n\nChoose an action:"
|
||||
@@ -87,7 +106,7 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event
|
||||
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("2006-01-02")
|
||||
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()
|
||||
@@ -101,7 +120,7 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event
|
||||
lines += "\n\n"
|
||||
|
||||
for _, e := range events {
|
||||
t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04")
|
||||
t := time.Unix(e.OccurredAt, 0).In(loc).Format(TimeLayout)
|
||||
label := "IN"
|
||||
if e.EventType == "out" {
|
||||
label = "OUT"
|
||||
@@ -122,6 +141,13 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event
|
||||
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
|
||||
}
|
||||
|
||||
@@ -137,7 +163,7 @@ func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int6
|
||||
loc = time.UTC
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
today := now.Format("2006-01-02")
|
||||
today := now.Format(DateLayout)
|
||||
|
||||
day, err := h.DB.GetOrCreateDay(user.ID, today)
|
||||
if err != nil {
|
||||
|
||||
316
internal/bot/rpg.go
Normal file
316
internal/bot/rpg.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"worktimeBot/internal/db"
|
||||
)
|
||||
|
||||
// XP earned per second of work.
|
||||
const xpPerSecond int64 = 1
|
||||
|
||||
// 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 / xpPerSecond
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// computeAndAwardXP calculates XP for a work session and updates the DB.
|
||||
// Returns the new total XP and whether the user leveled up.
|
||||
func (h *Handler) computeAndAwardXP(userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) {
|
||||
stats, err := h.DB.GetOrCreateRPGStats(userID)
|
||||
if err != nil {
|
||||
return 0, false, 0, err
|
||||
}
|
||||
|
||||
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.
|
||||
// Returns the updated streak count.
|
||||
func (h *Handler) updateStreak(userID int64, date string, hasWorkToday bool) (int, error) {
|
||||
stats, err := h.DB.GetOrCreateRPGStats(userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if !hasWorkToday {
|
||||
stats.CurrentStreak = 0
|
||||
if err := h.DB.UpdateRPGStats(stats); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Parse yesterday to check continuity
|
||||
t, err := time.Parse(DateLayout, date)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
if err := h.DB.UpdateRPGStats(stats); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return stats.CurrentStreak, nil
|
||||
}
|
||||
|
||||
// checkAchievements evaluates and unlocks any newly earned achievements.
|
||||
func (h *Handler) checkAchievements(userID int64, stats *db.RPGStats, workSeconds int64, date string, isWeekend bool) []string {
|
||||
unlocker := newAchievementUnlocker(h, userID)
|
||||
h.checkEarlyAchievements(unlocker, workSeconds)
|
||||
h.checkEventTimeAchievements(unlocker, userID, date)
|
||||
h.checkStreakAchievements(unlocker, stats)
|
||||
h.checkHourAchievements(unlocker, stats)
|
||||
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(stats.TotalWorkSeconds) / secondsPerHour
|
||||
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) 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
|
||||
}
|
||||
return h.buildRPGStatus(user.ID), nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
173
internal/bot/rpg_test.go
Normal file
173
internal/bot/rpg_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestXpForLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
n int
|
||||
want int64
|
||||
}{
|
||||
{0, 0},
|
||||
{1, 100},
|
||||
{2, 300},
|
||||
{5, 1500},
|
||||
{10, 5500},
|
||||
{27, 37800},
|
||||
{50, 127500},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := xpForLevel(c.n)
|
||||
if got != c.want {
|
||||
t.Errorf("xpForLevel(%d) = %d, want %d", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelForXP(t *testing.T) {
|
||||
cases := []struct {
|
||||
xp int64
|
||||
want int
|
||||
}{
|
||||
{0, 0},
|
||||
{50, 0},
|
||||
{99, 0},
|
||||
{100, 1},
|
||||
{299, 1},
|
||||
{300, 2},
|
||||
{5500, 10},
|
||||
{37800, 27},
|
||||
{127500, 50},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := levelForXP(c.xp)
|
||||
if got != c.want {
|
||||
t.Errorf("levelForXP(%d) = %d, want %d", c.xp, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelForXP_Monotonic(t *testing.T) {
|
||||
for n := 0; n <= 100; n++ {
|
||||
xp := xpForLevel(n)
|
||||
l := levelForXP(xp)
|
||||
if l != n {
|
||||
t.Errorf("levelForXP(xpForLevel(%d)) = %d, want %d", n, l, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXpForWorkSeconds(t *testing.T) {
|
||||
if got := xpForWorkSeconds(0); got != 0 {
|
||||
t.Errorf("xpForWorkSeconds(0) = %d, want 0", got)
|
||||
}
|
||||
if got := xpForWorkSeconds(3600); got != 3600 {
|
||||
t.Errorf("xpForWorkSeconds(3600) = %d, want 3600", got)
|
||||
}
|
||||
if got := xpForWorkSeconds(1800); got != 1800 {
|
||||
t.Errorf("xpForWorkSeconds(1800) = %d, want 1800", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreakBonus(t *testing.T) {
|
||||
cases := []struct {
|
||||
streak int
|
||||
want int64
|
||||
}{
|
||||
{0, 0},
|
||||
{1, 50},
|
||||
{10, 500},
|
||||
{5, 250},
|
||||
{20, 500},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := streakBonus(c.streak)
|
||||
if got != c.want {
|
||||
t.Errorf("streakBonus(%d) = %d, want %d", c.streak, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencySymbol(t *testing.T) {
|
||||
cases := []struct {
|
||||
currency string
|
||||
want string
|
||||
}{
|
||||
{"", "$"},
|
||||
{"$", "$"},
|
||||
{"$ USD", "$"},
|
||||
{"€ EUR", "€"},
|
||||
{"£", "£"},
|
||||
{"Toman", "Toman"},
|
||||
{" ", "$"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := currencySymbol(c.currency)
|
||||
if got != c.want {
|
||||
t.Errorf("currencySymbol(%q) = %q, want %q", c.currency, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDailySalary(t *testing.T) {
|
||||
// 1 hour at $25/hr
|
||||
got := computeDailySalary(3600, 25.0)
|
||||
if got != 25.0 {
|
||||
t.Errorf("computeDailySalary(3600, 25) = %.2f, want 25.00", got)
|
||||
}
|
||||
|
||||
// 2.5 hours at $20/hr = $50
|
||||
got = computeDailySalary(9000, 20.0)
|
||||
if got != 50.0 {
|
||||
t.Errorf("computeDailySalary(9000, 20) = %.2f, want 50.00", got)
|
||||
}
|
||||
|
||||
// 0 seconds = $0
|
||||
got = computeDailySalary(0, 50.0)
|
||||
if got != 0 {
|
||||
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)
|
||||
if got != 10.0 {
|
||||
t.Errorf("computeDailySalary(3599, 10) = %.2f, want 10.00", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeNote(t *testing.T) {
|
||||
// Trims spaces
|
||||
if got := SanitizeNote(" hello "); got != "hello" {
|
||||
t.Errorf("SanitizeNote trim: got %q", got)
|
||||
}
|
||||
|
||||
// Removes control chars except newline/tab
|
||||
if got := SanitizeNote("he\x00llo\nworld\t!"); got != "hello\nworld\t!" {
|
||||
t.Errorf("SanitizeNote control: got %q", got)
|
||||
}
|
||||
|
||||
// Preserves newlines and tabs
|
||||
if got := SanitizeNote("line1\nline2\tindented"); got != "line1\nline2\tindented" {
|
||||
t.Errorf("SanitizeNote newline/tab: got %q", got)
|
||||
}
|
||||
|
||||
// Truncates long notes
|
||||
long := string(make([]byte, 600))
|
||||
for i := range long {
|
||||
long = long[:i] + "a" + long[i+1:]
|
||||
}
|
||||
got := SanitizeNote(long)
|
||||
if len(got) > 500 {
|
||||
t.Errorf("SanitizeNote length: got %d, want <= 500", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeDisplayName(t *testing.T) {
|
||||
if got := SanitizeDisplayName(" Alice "); got != "Alice" {
|
||||
t.Errorf("SanitizeDisplayName trim: got %q", got)
|
||||
}
|
||||
if got := SanitizeDisplayName("Ali\x00ce"); got != "Alice" {
|
||||
t.Errorf("SanitizeDisplayName control: got %q", got)
|
||||
}
|
||||
}
|
||||
259
internal/bot/salary.go
Normal file
259
internal/bot/salary.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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))
|
||||
}
|
||||
|
||||
// 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 EUR\n/setcurrency Toman", st.Currency))
|
||||
return
|
||||
}
|
||||
parts := strings.Fields(args)
|
||||
if len(parts[0]) > 10 || strings.ContainsFunc(parts[0], func(r rune) bool { return r < 32 || r > 126 }) {
|
||||
h.sendText(ctx, msg.Chat.ID, "Invalid currency symbol. Use a short printable string like $, €, £, or Toman.")
|
||||
return
|
||||
}
|
||||
currency := parts[0]
|
||||
if len(parts) >= 2 {
|
||||
currency = parts[0] + " " + parts[1]
|
||||
}
|
||||
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 currency info (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 := fmt.Sprintf("Currency: %s\n\nUse /setcurrency <symbol> [code] to change it.\nExamples:\n/setcurrency $ USD\n/setcurrency EUR\n/setcurrency Toman", st.Currency)
|
||||
kb := models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Back to Settings", CallbackData: "back_settings"}},
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
41
internal/bot/sanitize.go
Normal file
41
internal/bot/sanitize.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxNoteLength = 500
|
||||
const maxUsernameLength = 64
|
||||
|
||||
// SanitizeNote cleans user-provided note text: trims whitespace, limits length,
|
||||
// and removes control characters (keeps newlines).
|
||||
func SanitizeNote(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > maxNoteLength {
|
||||
s = s[:maxNoteLength]
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\t' || r == ' ' {
|
||||
return r
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// SanitizeDisplayName cleans a display name (username) for safe rendering.
|
||||
func SanitizeDisplayName(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > maxUsernameLength {
|
||||
s = s[:maxUsernameLength]
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// 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 { // "/setreporttime " is 15 chars
|
||||
if len(msg.Text) > 16 {
|
||||
args = strings.TrimSpace(msg.Text[15:])
|
||||
}
|
||||
if args == "" {
|
||||
@@ -24,7 +24,8 @@ func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message)
|
||||
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", user.ReportTime))
|
||||
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] != ':' {
|
||||
@@ -42,8 +43,13 @@ func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message)
|
||||
h.sendText(ctx, msg.Chat.ID, "Error updating report time")
|
||||
return
|
||||
}
|
||||
user.ReportTime = args
|
||||
if err := h.DB.UpdateUser(user); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -57,7 +63,12 @@ func (h *Handler) handleAccentMsg(ctx context.Context, msg *models.Message) {
|
||||
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
text, kb := h.buildAccentKeyboard(user)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -92,20 +103,23 @@ func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message) {
|
||||
return
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now.Format("15:04")))
|
||||
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now.Format(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 300
|
||||
return defaultBreakThreshold
|
||||
}
|
||||
now := time.Now()
|
||||
date := now.Format("2006-01-02")
|
||||
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 300
|
||||
return defaultBreakThreshold
|
||||
}
|
||||
return day.MinBreakThreshold
|
||||
}
|
||||
@@ -133,7 +147,7 @@ func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
date := now.Format("2006-01-02")
|
||||
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")
|
||||
@@ -149,30 +163,45 @@ func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) {
|
||||
|
||||
// settingsCallback shows the settings inline menu.
|
||||
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
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, bt)
|
||||
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, breakThreshold int64) (string, models.InlineKeyboardMarkup) {
|
||||
func (h *Handler) buildSettingsKeyboard(user *db.User, st *db.UserSettings, breakThreshold int64) (string, models.InlineKeyboardMarkup) {
|
||||
reportStatus := "disabled"
|
||||
if user.ReportEnabled {
|
||||
if st.ReportEnabled {
|
||||
reportStatus = "enabled"
|
||||
}
|
||||
rpgStatus := "disabled"
|
||||
if st.RPGEnabled {
|
||||
rpgStatus = "enabled"
|
||||
}
|
||||
leagueStatus := "disabled"
|
||||
if st.LeagueOptIn {
|
||||
leagueStatus = "on"
|
||||
}
|
||||
rows := [][]models.InlineKeyboardButton{
|
||||
{{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}},
|
||||
{{Text: fmt.Sprintf("Accent: %s", user.ExportAccent), CallbackData: "accent"}},
|
||||
{{Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"}},
|
||||
{{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"}},
|
||||
{{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"}},
|
||||
{{Text: fmt.Sprintf("Report time: %s", user.ReportTime), CallbackData: "reporttime"}},
|
||||
{{Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"}},
|
||||
{{Text: fmt.Sprintf("Break threshold: %dm", breakThreshold/60), CallbackData: "breakthreshold"}},
|
||||
{{Text: "History", CallbackData: "history"}},
|
||||
{{Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"}},
|
||||
{{Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"}},
|
||||
{{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"}},
|
||||
{{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"}},
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
}
|
||||
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
@@ -180,18 +209,22 @@ func (h *Handler) buildSettingsKeyboard(user *db.User, breakThreshold int64) (st
|
||||
|
||||
// accentCallback shows the accent color picker (inline edit).
|
||||
func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
defer h.answerCb(ctx, callbackID)
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text, kb := h.buildAccentKeyboard(user)
|
||||
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) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
defer h.answerCb(ctx, callbackID)
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -206,7 +239,7 @@ func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int,
|
||||
}
|
||||
|
||||
// buildAccentKeyboard returns the accent color picker keyboard.
|
||||
func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeyboardMarkup) {
|
||||
func (h *Handler) buildAccentKeyboard(st *db.UserSettings) (string, models.InlineKeyboardMarkup) {
|
||||
type accentOption struct {
|
||||
name string
|
||||
color string
|
||||
@@ -220,7 +253,7 @@ func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeybo
|
||||
rows := [][]models.InlineKeyboardButton{}
|
||||
for _, a := range accents {
|
||||
label := fmt.Sprintf("%s (%s)", a.name, a.color)
|
||||
if a.name == user.ExportAccent {
|
||||
if a.name == st.ExportAccent {
|
||||
label = "> " + label
|
||||
}
|
||||
rows = append(rows, []models.InlineKeyboardButton{
|
||||
@@ -235,7 +268,7 @@ func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeybo
|
||||
|
||||
// selectAccent sets the user's export accent color after validation.
|
||||
func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
defer h.answerCb(ctx, callbackID)
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -244,8 +277,12 @@ func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cal
|
||||
if !valid[name] {
|
||||
return
|
||||
}
|
||||
user.ExportAccent = name
|
||||
if err := h.DB.UpdateUser(user); err != nil {
|
||||
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{
|
||||
@@ -258,12 +295,16 @@ func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cal
|
||||
|
||||
// reportTimeCallback shows the current report time setting.
|
||||
func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
defer h.answerCb(ctx, callbackID)
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", user.ReportTime)
|
||||
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"}},
|
||||
@@ -274,7 +315,7 @@ func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID in
|
||||
|
||||
// breakThresholdCallback shows the break threshold picker.
|
||||
func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
defer h.answerCb(ctx, callbackID)
|
||||
bt := h.getTodayBreakThreshold(chatID)
|
||||
text, kb := h.buildBreakThresholdKeyboard(bt)
|
||||
h.editText(ctx, chatID, msgID, text, &kb)
|
||||
@@ -309,9 +350,9 @@ func (h *Handler) buildBreakThresholdKeyboard(current int64) (string, models.Inl
|
||||
|
||||
// selectBreakThreshold saves the chosen break threshold and returns to settings.
|
||||
func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, callbackID, valStr string) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
defer h.answerCb(ctx, callbackID)
|
||||
mins, err := strconv.Atoi(valStr)
|
||||
if err != nil || mins < 0 || mins > 480 {
|
||||
if err != nil || mins < 0 || mins > maxBreakThresholdMins {
|
||||
return
|
||||
}
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
@@ -319,7 +360,7 @@ func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
date := now.Format("2006-01-02")
|
||||
date := now.Format(DateLayout)
|
||||
day, err := h.DB.GetOrCreateDay(user.ID, date)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -329,25 +370,33 @@ func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID
|
||||
return
|
||||
}
|
||||
bt := h.getTodayBreakThreshold(chatID)
|
||||
text, kb := h.buildSettingsKeyboard(user, bt)
|
||||
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) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
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, bt)
|
||||
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) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
defer h.answerCb(ctx, callbackID)
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -382,7 +431,7 @@ func (h *Handler) buildCalendarKeyboard(user *db.User) (string, models.InlineKey
|
||||
|
||||
// selectCalendar sets the user's calendar after validation.
|
||||
func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
|
||||
h.answerCb(ctx, callbackID)
|
||||
defer h.answerCb(ctx, callbackID)
|
||||
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
|
||||
if !valid[name] {
|
||||
return
|
||||
@@ -396,6 +445,185 @@ func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, c
|
||||
return
|
||||
}
|
||||
bt := h.getTodayBreakThreshold(chatID)
|
||||
text, kb := h.buildSettingsKeyboard(user, bt)
|
||||
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)
|
||||
}
|
||||
|
||||
// handleLeagueSettingsMsg shows the League enable/disable picker (command).
|
||||
func (h *Handler) handleLeagueSettingsMsg(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.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
|
||||
}
|
||||
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)
|
||||
}
|
||||
case "league_enable":
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,14 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
|
||||
if totalBreak > 0 {
|
||||
b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak)))
|
||||
}
|
||||
|
||||
st, _ := h.DB.GetOrCreateSettings(user.ID)
|
||||
if st.HourlyRate > 0 {
|
||||
est := computeMonthlySalary(days, h.DB, user.ID, st.HourlyRate, loc)
|
||||
b.WriteString(fmt.Sprintf("\nSalary: %s (%.1f hours, %d days)",
|
||||
est.formatSalary(st.Currency), est.WorkHours, est.WorkDays))
|
||||
}
|
||||
|
||||
b.WriteString("\n\nDetails:\n")
|
||||
for _, d := range dayDetails {
|
||||
b.WriteString(d)
|
||||
@@ -115,39 +123,3 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// computeWeekTotals returns the total work seconds for the current week (Mon-Sun containing today).
|
||||
func (h *Handler) computeWeekTotals(user *db.User, loc *time.Location) int64 {
|
||||
now := time.Now().In(loc)
|
||||
weekday := now.Weekday()
|
||||
if weekday == time.Sunday {
|
||||
weekday = 7
|
||||
}
|
||||
monday := now.AddDate(0, 0, -int(weekday-time.Monday))
|
||||
sunday := monday.AddDate(0, 0, 6)
|
||||
|
||||
startStr := monday.Format("2006-01-02")
|
||||
endStr := sunday.Format("2006-01-02")
|
||||
|
||||
days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var total int64
|
||||
for _, day := range days {
|
||||
if day.IsDayOff {
|
||||
continue
|
||||
}
|
||||
events, err := h.DB.EventsForDayByDayID(day.ID)
|
||||
if err != nil || len(events) == 0 {
|
||||
continue
|
||||
}
|
||||
y, m, d := parseGregorianDateKey(day.Date)
|
||||
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
|
||||
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
|
||||
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
|
||||
total += totals.TotalSeconds
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Package bot implements the Telegram bot logic: message routing, time tracking,
|
||||
// calendar rendering, Excel export, and inline editing of events.
|
||||
package bot
|
||||
|
||||
import (
|
||||
|
||||
101
internal/db/migrations/002_features.sql
Normal file
101
internal/db/migrations/002_features.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
|
||||
export_accent TEXT NOT NULL DEFAULT 'ocean'
|
||||
CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')),
|
||||
report_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
report_time TEXT NOT NULL DEFAULT '23:00',
|
||||
last_report_date TEXT NOT NULL DEFAULT '',
|
||||
rpg_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
league_opt_in INTEGER NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT '$ USD',
|
||||
hourly_rate REAL NOT NULL DEFAULT 0.0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_settings (user_id)
|
||||
SELECT id FROM users;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_rpg (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
|
||||
xp INTEGER NOT NULL DEFAULT 0,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
current_streak INTEGER NOT NULL DEFAULT 0,
|
||||
longest_streak INTEGER NOT NULL DEFAULT 0,
|
||||
total_work_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_rpg (user_id)
|
||||
SELECT id FROM users;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_xp (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
date TEXT NOT NULL,
|
||||
xp_earned INTEGER NOT NULL DEFAULT 0,
|
||||
work_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE(user_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO achievements (code, name, description) VALUES
|
||||
('first_clockin', 'First Steps', 'Clock in for the first time'),
|
||||
('early_bird', 'Early Bird', 'Clock in before 7 AM'),
|
||||
('night_owl', 'Night Owl', 'Clock out after 10 PM'),
|
||||
('iron_streak_5', 'Iron Streak', 'Work 5 consecutive days'),
|
||||
('iron_streak_10', 'Iron Streak II', 'Work 10 consecutive days'),
|
||||
('iron_streak_30', 'Iron Streak III', 'Work 30 consecutive days'),
|
||||
('remote_veteran', 'Remote Veteran', 'Log 100 hours of remote work'),
|
||||
('onsite_master', 'Onsite Master', 'Log 100 hours of onsite work'),
|
||||
('consistency_master', 'Consistency Master', 'Work at least 6 hours daily for a week'),
|
||||
('perfect_week', 'Perfect Week', 'No days off for a full week'),
|
||||
('overtime_hero', 'Overtime Hero', 'Work more than 10 hours in a day'),
|
||||
('level_5', 'Getting Started', 'Reach level 5'),
|
||||
('level_10', 'Dedicated', 'Reach level 10'),
|
||||
('level_25', 'Veteran', 'Reach level 25'),
|
||||
('level_50', 'Legend', 'Reach level 50'),
|
||||
('weekend_warrior', 'Weekend Warrior', 'Work on a weekend day'),
|
||||
('hundred_hours', 'Century', 'Log 100 total work hours'),
|
||||
('five_hundred_hours', 'Iron Will', 'Log 500 total work hours'),
|
||||
('salary_setter', 'Salary Tracker', 'Configure your hourly rate'),
|
||||
('rpg_pioneer', 'RPG Pioneer', 'Enable the RPG system');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
achievement_id INTEGER NOT NULL REFERENCES achievements(id),
|
||||
unlocked_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE(user_id, achievement_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_xp_user_date ON daily_xp(user_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_achievements;
|
||||
DROP TABLE IF EXISTS achievements;
|
||||
DROP TABLE IF EXISTS daily_xp;
|
||||
DROP TABLE IF EXISTS user_rpg;
|
||||
DROP TABLE IF EXISTS user_settings;
|
||||
CREATE TABLE IF NOT EXISTS users_old AS SELECT * FROM users;
|
||||
DROP TABLE IF EXISTS users;
|
||||
-- Note: restoring users table without the username column requires
|
||||
-- recreating it from the backup. In practice this migration is safe
|
||||
-- and the down path preserves data via the backup table.
|
||||
ALTER TABLE users_old RENAME TO users;
|
||||
@@ -4,6 +4,7 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
@@ -21,21 +22,75 @@ type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// User represents a bot user with timezone, reporting preferences, and calendar type.
|
||||
// User represents a bot user with identity, timezone, and calendar preferences.
|
||||
type User struct {
|
||||
ID int64
|
||||
ChatID int64
|
||||
Timezone string
|
||||
IsActive bool
|
||||
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
|
||||
ExportAccent string
|
||||
Calendar 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
|
||||
@@ -345,11 +400,34 @@ func migrateSettings(db *sql.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// usernames is a pool of fun default usernames assigned randomly to new users.
|
||||
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))]
|
||||
}
|
||||
|
||||
// GetOrCreateUser retrieves a user by chat ID, creating a new record if none exists.
|
||||
func (s *Store) GetOrCreateUser(chatID int64) (*User, error) {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO users (chat_id) VALUES (?)",
|
||||
chatID,
|
||||
"INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)",
|
||||
chatID, randomUsername(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -360,39 +438,25 @@ func (s *Store) GetOrCreateUser(chatID int64) (*User, error) {
|
||||
// GetUserByChatID retrieves a single user by their Telegram chat ID.
|
||||
func (s *Store) GetUserByChatID(chatID int64) (*User, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, chat_id, timezone, is_active, report_enabled, report_time,
|
||||
last_report_date, export_accent, calendar, created_at, updated_at
|
||||
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
|
||||
FROM users WHERE chat_id = ?
|
||||
`, chatID)
|
||||
var u User
|
||||
var lastReport sql.NullString
|
||||
err := row.Scan(
|
||||
&u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime,
|
||||
&lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
|
||||
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastReport.Valid {
|
||||
u.LastReportDate = &lastReport.String
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UpdateUser persists all fields of the given user record.
|
||||
// UpdateUser persists core fields of the user record.
|
||||
func (s *Store) UpdateUser(user *User) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE users SET timezone=?, is_active=?, report_enabled=?, report_time=?, last_report_date=?, export_accent=?, calendar=?, updated_at=unixepoch() WHERE id=?",
|
||||
user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.Calendar, user.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkReportSent records the last report date for a user to prevent duplicate daily reports.
|
||||
func (s *Store) MarkReportSent(userID int64, date string) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE users SET last_report_date=?, updated_at=unixepoch() WHERE id=?",
|
||||
date, userID,
|
||||
"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
|
||||
}
|
||||
@@ -400,8 +464,7 @@ func (s *Store) MarkReportSent(userID int64, date string) error {
|
||||
// GetAllUsers returns all users in the database.
|
||||
func (s *Store) GetAllUsers() ([]User, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, chat_id, timezone, is_active, report_enabled, report_time,
|
||||
last_report_date, export_accent, calendar, created_at, updated_at
|
||||
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
|
||||
FROM users
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -412,21 +475,289 @@ func (s *Store) GetAllUsers() ([]User, error) {
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
var lastReport sql.NullString
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime,
|
||||
&lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
|
||||
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastReport.Valid {
|
||||
u.LastReportDate = &lastReport.String
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// -- User Settings --
|
||||
|
||||
// GetOrCreateSettings retrieves settings for a user, creating defaults if none exist.
|
||||
func (s *Store) GetOrCreateSettings(userID int64) (*UserSettings, error) {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)",
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSettings(userID)
|
||||
}
|
||||
|
||||
// GetSettings retrieves the settings for a user.
|
||||
func (s *Store) GetSettings(userID int64) (*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 UserSettings
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// UpdateSettings persists all fields of the user settings.
|
||||
func (s *Store) UpdateSettings(st *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
|
||||
}
|
||||
|
||||
// MarkReportSent records the last report date for a user.
|
||||
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)
|
||||
}
|
||||
|
||||
// GetReportEnabled returns whether daily reports are enabled for the user.
|
||||
func (s *Store) GetReportEnabled(userID int64) bool {
|
||||
st, err := s.GetOrCreateSettings(userID)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return st.ReportEnabled
|
||||
}
|
||||
|
||||
// -- RPG Stats --
|
||||
|
||||
// GetOrCreateRPGStats retrieves RPG stats for a user, creating defaults if none exist.
|
||||
func (s *Store) GetOrCreateRPGStats(userID int64) (*RPGStats, error) {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO user_rpg (user_id) VALUES (?)",
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetRPGStats(userID)
|
||||
}
|
||||
|
||||
// GetRPGStats retrieves the RPG stats for a user.
|
||||
func (s *Store) GetRPGStats(userID int64) (*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 RPGStats
|
||||
err := row.Scan(
|
||||
&st.UserID, &st.XP, &st.Level, &st.CurrentStreak, &st.LongestStreak,
|
||||
&st.TotalWorkSeconds, &st.CreatedAt, &st.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// UpdateRPGStats persists the RPG stats for a user.
|
||||
func (s *Store) UpdateRPGStats(st *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 --
|
||||
|
||||
// UpsertDailyXP inserts or updates the daily XP record for a user/date.
|
||||
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
|
||||
}
|
||||
|
||||
// GetDailyXP returns the daily XP record for a user/date.
|
||||
func (s *Store) GetDailyXP(userID int64, date string) (*DailyXP, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, user_id, date, xp_earned, work_seconds, created_at
|
||||
FROM daily_xp WHERE user_id=? AND date=?
|
||||
`, userID, date)
|
||||
var dx DailyXP
|
||||
err := row.Scan(&dx.ID, &dx.UserID, &dx.Date, &dx.XPEarned, &dx.WorkSeconds, &dx.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dx, nil
|
||||
}
|
||||
|
||||
// -- Achievements --
|
||||
|
||||
// GetAllAchievements returns all defined achievements.
|
||||
func (s *Store) GetAllAchievements() ([]Achievement, error) {
|
||||
rows, err := s.db.Query("SELECT id, code, name, description, icon FROM achievements ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var achs []Achievement
|
||||
for rows.Next() {
|
||||
var a Achievement
|
||||
if err := rows.Scan(&a.ID, &a.Code, &a.Name, &a.Description, &a.Icon); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
achs = append(achs, a)
|
||||
}
|
||||
return achs, rows.Err()
|
||||
}
|
||||
|
||||
// GetAchievementByCode returns an achievement by its code string.
|
||||
func (s *Store) GetAchievementByCode(code string) (*Achievement, error) {
|
||||
var a 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
|
||||
}
|
||||
|
||||
// GetUserAchievements returns all achievements unlocked by a user.
|
||||
func (s *Store) GetUserAchievements(userID int64) ([]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 []UserAchievement
|
||||
for rows.Next() {
|
||||
var ua 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()
|
||||
}
|
||||
|
||||
// UnlockAchievement grants an achievement to a user (no-op if already owned).
|
||||
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
|
||||
}
|
||||
|
||||
// HasAchievement checks whether a user has unlocked a specific achievement code.
|
||||
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 / Rankings --
|
||||
|
||||
// LeagueEntry represents a user in a league ranking.
|
||||
type LeagueEntry struct {
|
||||
UserID int64
|
||||
Username string
|
||||
XP int64
|
||||
Level int
|
||||
Streak int
|
||||
TotalHours float64
|
||||
}
|
||||
|
||||
// GetLeagueRankings returns all users who have opted into the league, ordered by the given column.
|
||||
func (s *Store) GetLeagueRankings(orderBy string) ([]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 []LeagueEntry
|
||||
for rows.Next() {
|
||||
var e 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()
|
||||
}
|
||||
|
||||
// GetWorkTypes returns all work types ordered by ID.
|
||||
func (s *Store) GetWorkTypes() ([]WorkType, error) {
|
||||
rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")
|
||||
|
||||
Reference in New Issue
Block a user