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()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
for _, u := range users {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,11 +119,11 @@ func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) {
|
|||||||
today := localNow.Format("2006-01-02")
|
today := localNow.Format("2006-01-02")
|
||||||
currentHHMM := localNow.Format("15:04")
|
currentHHMM := localNow.Format("15:04")
|
||||||
|
|
||||||
if currentHHMM != u.ReportTime {
|
if currentHHMM != st.ReportTime {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.LastReportDate != nil && *u.LastReportDate == today {
|
if st.LastReportDate == today {
|
||||||
continue
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var y, m int
|
if h.handleHistoryCalendarNav(ctx, chatID, msgID, data) {
|
||||||
var date string
|
return
|
||||||
var eid int64
|
}
|
||||||
|
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 {
|
if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 {
|
||||||
h.editCalendar(ctx, chatID, msgID, y, m)
|
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 {
|
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 {
|
||||||
h.editCalendar(ctx, chatID, msgID, y, m)
|
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 {
|
if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 {
|
||||||
h.editCalendar(ctx, chatID, msgID, y, m)
|
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 {
|
if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 {
|
||||||
h.editCalendar(ctx, chatID, msgID, y, m)
|
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 {
|
if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 {
|
||||||
h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y)
|
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 {
|
if n, _ := fmt.Sscanf(data, "cal_years_%d_%d_%d", &startY, &refY, &refM); n == 3 {
|
||||||
h.editCalendarYearPicker(ctx, chatID, msgID, startY+6, refM, refY)
|
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 {
|
if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 {
|
||||||
h.editCalendar(ctx, chatID, msgID, y, 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 {
|
if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 {
|
||||||
h.editCalendar(ctx, chatID, msgID, y, m)
|
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 {
|
if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 {
|
||||||
h.editDayView(ctx, chatID, msgID, user, date, loc)
|
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 {
|
if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 {
|
||||||
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
|
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// Set event work type
|
return false
|
||||||
var wtid int64
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 {
|
||||||
h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc)
|
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 {
|
if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 {
|
||||||
h.editEventType(ctx, chatID, msgID, user, eid, loc)
|
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 {
|
if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 {
|
||||||
h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc)
|
h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// Confirm event deletion
|
|
||||||
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
|
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
|
||||||
h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc)
|
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 {
|
if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 {
|
||||||
h.editEventTime(ctx, chatID, msgID, user, eid, loc)
|
h.editEventTime(ctx, chatID, msgID, user, eid, loc)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// Set event time (hour+minute)
|
|
||||||
var hh, mm int
|
var hh, mm int
|
||||||
if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 {
|
if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 {
|
||||||
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc)
|
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc, callbackID)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// Show minute picker (hour already chosen)
|
|
||||||
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
|
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
|
||||||
h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc)
|
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 {
|
if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 {
|
||||||
h.promptNote(ctx, chatID, msgID, user, eid, loc)
|
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 {
|
if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 {
|
||||||
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
|
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// Add IN event — show hour picker
|
return false
|
||||||
if strings.HasPrefix(data, "addin_") {
|
}
|
||||||
|
|
||||||
|
// 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)
|
h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc)
|
||||||
return
|
case strings.HasPrefix(data, "addout_"):
|
||||||
}
|
|
||||||
// Add OUT event — show hour picker
|
|
||||||
if strings.HasPrefix(data, "addout_") {
|
|
||||||
h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc)
|
h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc)
|
||||||
return
|
case strings.HasPrefix(data, "addtm_"):
|
||||||
}
|
|
||||||
// Add event hour/minute callbacks
|
|
||||||
if strings.HasPrefix(data, "addtm_") {
|
|
||||||
h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc)
|
h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc)
|
||||||
return
|
default:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendCalendar sends a calendar view as a new message.
|
// 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)
|
cm := buildCalendarMonth(cal, year, month)
|
||||||
|
|
||||||
text := cm.title
|
text := cm.title
|
||||||
todayKey := now.Format("2006-01-02")
|
todayKey := now.Format(DateLayout)
|
||||||
|
|
||||||
hasEvent := make(map[string]bool)
|
hasEvent := make(map[string]bool)
|
||||||
startDate, endDate := monthGregorianRange(cal, year, month)
|
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:"
|
text += "\n\nEvents:"
|
||||||
kbRows := [][]models.InlineKeyboardButton{}
|
kbRows := [][]models.InlineKeyboardButton{}
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04")
|
t := time.Unix(e.OccurredAt, 0).In(loc).Format(TimeLayout)
|
||||||
label := "IN"
|
label := "IN"
|
||||||
if e.EventType == "out" {
|
if e.EventType == "out" {
|
||||||
label = "OUT"
|
label = "OUT"
|
||||||
@@ -435,7 +450,7 @@ func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
t := time.Unix(event.OccurredAt, 0).In(loc)
|
||||||
h.sendDayView(ctx, chatID, msgID, user, t.Format("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.
|
// 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.
|
// 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)
|
event, err := h.getEventByID(user.ID, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
t := time.Unix(event.OccurredAt, 0).In(loc)
|
||||||
newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc)
|
newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc)
|
||||||
date := t.Format("2006-01-02")
|
date := t.Format(DateLayout)
|
||||||
|
|
||||||
// Reject if the new time collides with another event's timestamp
|
// Reject if the new time collides with another event's timestamp
|
||||||
events, err := h.DB.EventsForDayByDate(user.ID, date)
|
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)
|
eTime := time.Unix(e.OccurredAt, 0)
|
||||||
if newTime.Unix() == eTime.Unix() {
|
if newTime.Unix() == eTime.Unix() {
|
||||||
h.acknowledgeCallback(ctx, chatID, msgID)
|
h.acknowledgeCallback(ctx, callbackID)
|
||||||
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
||||||
return
|
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.
|
// acknowledgeCallback sends an empty acknowledgement for a callback query.
|
||||||
func (h *Handler) acknowledgeCallback(ctx context.Context, chatID int64, msgID int) {
|
func (h *Handler) acknowledgeCallback(ctx context.Context, callbackID string) {
|
||||||
h.answerCb(ctx, fmt.Sprintf("cb_%d_%d", chatID, msgID))
|
h.answerCb(ctx, callbackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptNote asks the user to type a note for the event, then re-renders the day view.
|
// 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 {
|
if err != nil {
|
||||||
return
|
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.mu.Lock()
|
||||||
h.pendingNotes[chatID] = &pendingNote{eventID: eventID, createdAt: time.Now()}
|
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
|
return
|
||||||
}
|
}
|
||||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
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)
|
_, err = h.DB.DB().Exec("DELETE FROM events WHERE id=?", eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -630,7 +645,7 @@ func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, us
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
t := time.Unix(event.OccurredAt, 0).In(loc)
|
t := time.Unix(event.OccurredAt, 0).In(loc)
|
||||||
h.sendDayView(ctx, chatID, msgID, user, t.Format("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.
|
// 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)
|
loc := loadLocation(user.Timezone)
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
lastTime := time.Unix(last.OccurredAt, 0).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")
|
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 {
|
if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
|
||||||
return "", err
|
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.
|
// clockOut records a clock-out event for today.
|
||||||
// Returns the formatted time or an error if not clocked in.
|
// 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) {
|
func (h *Handler) clockOut(chatID int64) (string, error) {
|
||||||
user, day, err := h.getUserToday(chatID)
|
user, day, err := h.getUserToday(chatID)
|
||||||
if err != nil {
|
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 {
|
if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
|
||||||
return "", err
|
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.
|
// dayOff toggles today as a day off.
|
||||||
@@ -100,11 +124,15 @@ func (h *Handler) toggleReport(chatID int64) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
user.ReportEnabled = !user.ReportEnabled
|
st, err := h.DB.GetOrCreateSettings(user.ID)
|
||||||
if err := h.DB.UpdateUser(user); err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 enabled", nil
|
||||||
}
|
}
|
||||||
return "daily report disabled", nil
|
return "daily report disabled", nil
|
||||||
|
|||||||
@@ -28,6 +28,152 @@ var themes = map[string]themeColors{
|
|||||||
"catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"},
|
"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) {
|
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) {
|
||||||
theme, ok := themes[accent]
|
theme, ok := themes[accent]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -45,79 +191,15 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
|||||||
f.SetActiveSheet(index)
|
f.SetActiveSheet(index)
|
||||||
f.DeleteSheet("Sheet1")
|
f.DeleteSheet("Sheet1")
|
||||||
|
|
||||||
titleStyle, _ := f.NewStyle(&excelize.Style{
|
styles := newReportStyles(f, theme)
|
||||||
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"},
|
|
||||||
})
|
|
||||||
|
|
||||||
monthName := formatMonthTitle(calendar, calYear, calMonth)
|
monthName := formatMonthTitle(calendar, calYear, calMonth)
|
||||||
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
|
writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles)
|
||||||
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)
|
|
||||||
|
|
||||||
loc := loadLocation(timezone)
|
loc := loadLocation(timezone)
|
||||||
|
|
||||||
row := 3
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -130,10 +212,9 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
|||||||
var totalWork, totalBreak int64
|
var totalWork, totalBreak int64
|
||||||
|
|
||||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
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)
|
displayDate := formatDateForCalendar(dateStr, calendar)
|
||||||
|
|
||||||
// Determine if this day exists, its events, and day-off status.
|
|
||||||
var (
|
var (
|
||||||
day *db.Day
|
day *db.Day
|
||||||
events []db.Event
|
events []db.Event
|
||||||
@@ -148,26 +229,6 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
|||||||
hasDayOff = day.IsDayOff
|
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
|
var typeLabel string
|
||||||
if hasDayOff {
|
if hasDayOff {
|
||||||
if day.DayOffReason != "" {
|
if day.DayOffReason != "" {
|
||||||
@@ -178,47 +239,14 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
|||||||
} else if len(events) > 0 {
|
} else if len(events) > 0 {
|
||||||
typeLabel = workTypeLabel(store, day, events)
|
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++
|
||||||
}
|
}
|
||||||
|
|
||||||
row++
|
writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
buf, err := f.WriteToBuffer()
|
buf, err := f.WriteToBuffer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -253,16 +281,16 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fmtHHMM(seconds int64) string {
|
func fmtHHMM(seconds int64) string {
|
||||||
hours := seconds / 3600
|
hours := seconds / secondsPerHour
|
||||||
mins := (seconds % 3600) / 60
|
mins := (seconds % secondsPerHour) / 60
|
||||||
return fmt.Sprintf("%d:%02d", hours, mins)
|
return fmt.Sprintf("%d:%02d", hours, mins)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtDDHHMM(seconds int64) string {
|
func fmtDDHHMM(seconds int64) string {
|
||||||
days := seconds / 86400
|
days := seconds / secondsPerDay
|
||||||
rem := seconds % 86400
|
rem := seconds % secondsPerDay
|
||||||
hours := rem / 3600
|
hours := rem / secondsPerHour
|
||||||
mins := (rem % 3600) / 60
|
mins := (rem % secondsPerHour) / 60
|
||||||
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
|
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)
|
loc := loadLocation(user.Timezone)
|
||||||
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
|
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
|
||||||
start, err := time.Parse("2006-01-02", startStr)
|
start, err := time.Parse(DateLayout, startStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
||||||
}
|
}
|
||||||
end, err := time.Parse("2006-01-02", endStr)
|
end, err := time.Parse(DateLayout, endStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
||||||
end = startOfMonth.AddDate(0, 1, -1)
|
end = startOfMonth.AddDate(0, 1, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
st, _ := h.DB.GetOrCreateSettings(user.ID)
|
||||||
|
accent := st.ExportAccent
|
||||||
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
|
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
|
||||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, 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 {
|
if err != nil {
|
||||||
slog.Error("generate report", "error", err)
|
slog.Error("generate report", "error", err)
|
||||||
h.sendText(ctx, chatID, "Error generating report")
|
h.sendText(ctx, chatID, "Error generating report")
|
||||||
@@ -434,94 +464,102 @@ func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID
|
|||||||
return
|
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 {
|
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 {
|
||||||
h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user)
|
h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// Navigate to next year
|
|
||||||
if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 {
|
if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 {
|
||||||
h.editExportMonthPicker(ctx, chatID, msgID, y+1, 0, user)
|
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 {
|
if n, _ := fmt.Sscanf(data, "exp_show_years_%d", &y); n == 1 {
|
||||||
h.editExportYearPicker(ctx, chatID, msgID, y, y)
|
h.editExportYearPicker(ctx, chatID, msgID, y, y)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// Navigate year block in picker
|
|
||||||
var refY int
|
var refY int
|
||||||
if n, _ := fmt.Sscanf(data, "exp_years_%d_%d", &y, &refY); n == 2 {
|
if n, _ := fmt.Sscanf(data, "exp_years_%d_%d", &y, &refY); n == 2 {
|
||||||
h.editExportYearPicker(ctx, chatID, msgID, y+6, refY)
|
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 {
|
if n, _ := fmt.Sscanf(data, "exp_year_%d", &y); n == 1 {
|
||||||
h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user)
|
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 {
|
if n, _ := fmt.Sscanf(data, "exp_years_back_%d", &y); n == 1 {
|
||||||
h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user)
|
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
|
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)
|
last, err := h.DB.GetLastEvent(user.ID)
|
||||||
if err == nil && last != nil && last.EventType == "in" {
|
if err == nil && last != nil && last.EventType == "in" {
|
||||||
kb := models.InlineKeyboardMarkup{
|
backKB := models.InlineKeyboardMarkup{
|
||||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
{{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)
|
h.editText(ctx, chatID, msgID, "You are currently clocked in. Please clock out first, then try again.", &backKB)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
|
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
|
||||||
start, err := time.Parse("2006-01-02", startStr)
|
start, err := time.Parse(DateLayout, startStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.sendText(ctx, chatID, "Error processing date")
|
h.sendText(ctx, chatID, "Error processing date")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
end, err := time.Parse("2006-01-02", endStr)
|
end, err := time.Parse(DateLayout, endStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.sendText(ctx, chatID, "Error processing date")
|
h.sendText(ctx, chatID, "Error processing date")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
st, _ := h.DB.GetOrCreateSettings(user.ID)
|
||||||
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
|
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
|
||||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
|
reportData, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, st.ExportAccent, user.Calendar, y, m, start, end)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("generate report", "error", err)
|
slog.Error("generate report", "error", err)
|
||||||
kb := models.InlineKeyboardMarkup{
|
backKB := models.InlineKeyboardMarkup{
|
||||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
h.editText(ctx, chatID, msgID, "Error generating report", &kb)
|
h.editText(ctx, chatID, msgID, "Error generating report", &backKB)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("report generated", "bytes", len(data))
|
slog.Info("report generated", "bytes", len(reportData))
|
||||||
kb := models.InlineKeyboardMarkup{
|
backKB := models.InlineKeyboardMarkup{
|
||||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
h.editText(ctx, chatID, msgID, "Report ready:", &kb)
|
h.editText(ctx, chatID, msgID, "Report ready:", &backKB)
|
||||||
|
|
||||||
if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{
|
if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Document: &models.InputFileUpload{
|
Document: &models.InputFileUpload{
|
||||||
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
|
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
|
||||||
Data: bytes.NewReader(data),
|
Data: bytes.NewReader(reportData),
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("send document", "error", err)
|
slog.Error("send document", "error", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,21 @@ import (
|
|||||||
"worktimeBot/internal/db"
|
"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 {
|
type pendingNote struct {
|
||||||
eventID int64
|
eventID int64
|
||||||
@@ -129,10 +143,10 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
|
|||||||
loc := loadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
event, err := h.getEventByID(user.ID, pn.eventID)
|
event, err := h.getEventByID(user.ID, pn.eventID)
|
||||||
if err == nil {
|
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)
|
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)
|
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -183,6 +197,24 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
|
|||||||
h.handleSummaryMsg(ctx, msg)
|
h.handleSummaryMsg(ctx, msg)
|
||||||
case "/setbreak":
|
case "/setbreak":
|
||||||
h.handleSetBreakMsg(ctx, msg)
|
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:
|
default:
|
||||||
h.sendText(ctx, msg.Chat.ID, "Unknown command")
|
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)
|
h.timezoneCallback(ctx, chatID, msgID, cb.ID)
|
||||||
case "history":
|
case "history":
|
||||||
h.historyCallback(ctx, chatID, msgID, cb.ID)
|
h.historyCallback(ctx, chatID, msgID, cb.ID)
|
||||||
case "reporttoggle":
|
|
||||||
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.toggleReport)
|
|
||||||
case "reporttime":
|
case "reporttime":
|
||||||
h.reportTimeCallback(ctx, chatID, msgID, cb.ID)
|
h.reportTimeCallback(ctx, chatID, msgID, cb.ID)
|
||||||
case "breakthreshold":
|
case "breakthreshold":
|
||||||
@@ -242,29 +272,75 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
|
|||||||
h.backToMenu(ctx, chatID, msgID, cb.ID)
|
h.backToMenu(ctx, chatID, msgID, cb.ID)
|
||||||
case "back_settings":
|
case "back_settings":
|
||||||
h.backToSettings(ctx, chatID, msgID, cb.ID)
|
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:
|
default:
|
||||||
// Delegate prefixed callbacks to the appropriate sub-handler
|
h.handlePrefixedCallback(ctx, chatID, msgID, cb.ID, cb.Data)
|
||||||
if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" {
|
}
|
||||||
h.selectWorkType(ctx, chatID, msgID, cb.ID, cb.Data[9:])
|
}
|
||||||
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
if len(cb.Data) > 7 && cb.Data[:7] == "accent_" {
|
if len(data) > 7 && data[:7] == "accent_" {
|
||||||
h.selectAccent(ctx, chatID, msgID, cb.ID, cb.Data[7:])
|
h.selectAccent(ctx, chatID, msgID, callbackID, data[7:])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(cb.Data) > 8 && cb.Data[:8] == "caltype_" {
|
if len(data) > 8 && data[:8] == "caltype_" {
|
||||||
h.selectCalendar(ctx, chatID, msgID, cb.ID, cb.Data[8:])
|
h.selectCalendar(ctx, chatID, msgID, callbackID, data[8:])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(cb.Data) > 4 && cb.Data[:4] == "exp_" {
|
if len(data) > 4 && data[:4] == "exp_" {
|
||||||
h.handleExportCallback(ctx, chatID, msgID, cb.ID, cb.Data)
|
h.handleExportCallback(ctx, chatID, msgID, callbackID, data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(cb.Data) > 15 && cb.Data[:15] == "breakthreshold_" {
|
if len(data) > 15 && data[:15] == "breakthreshold_" {
|
||||||
h.selectBreakThreshold(ctx, chatID, msgID, cb.ID, cb.Data[15:])
|
h.selectBreakThreshold(ctx, chatID, msgID, callbackID, data[15:])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.handleHistoryCallback(ctx, chatID, msgID, cb.ID, cb.Data)
|
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 {
|
if err != nil {
|
||||||
loc = time.UTC
|
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)
|
day, err := h.DB.GetOrCreateDay(user.ID, today)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -313,16 +389,25 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
|
|||||||
/worktype - Select work type
|
/worktype - Select work type
|
||||||
/dayoff - Toggle day off
|
/dayoff - Toggle day off
|
||||||
/report - Show today report
|
/report - Show today report
|
||||||
/export [YYYY-MM] - Export monthly report (current month if omitted)
|
/export [YYYY-MM] - Export monthly report
|
||||||
/history - View calendar history
|
/history - View calendar history
|
||||||
/edit YYYY-MM-DD - Edit a specific day
|
/edit YYYY-MM-DD - Edit a specific day
|
||||||
/note [YYYY-MM-DD] HH:MM <text> - Set note for an event
|
/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
|
/reporttoggle - Enable/disable auto report
|
||||||
/setreporttime HH:MM - Set daily report time
|
/setreporttime HH:MM - Set daily report time
|
||||||
/setaccent - Select accent color
|
/setaccent - Select accent color
|
||||||
/settimezone <name> - Set your timezone (e.g. Asia/Tehran)
|
/settimezone <name> - Set timezone (e.g. Asia/Tehran)
|
||||||
/setbreak <minutes> - Set minimum break threshold in minutes
|
/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).
|
Dates use your calendar type (Gregorian/Jalali/Hijri).
|
||||||
Buttons on the main menu also work.`
|
Buttons on the main menu also work.`
|
||||||
@@ -354,7 +439,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
|
|||||||
timeStr = parts[2]
|
timeStr = parts[2]
|
||||||
noteParts = parts[3:]
|
noteParts = parts[3:]
|
||||||
} else {
|
} else {
|
||||||
dateStr = time.Now().In(loc).Format("2006-01-02")
|
dateStr = time.Now().In(loc).Format(DateLayout)
|
||||||
timeStr = parts[1]
|
timeStr = parts[1]
|
||||||
noteParts = parts[2:]
|
noteParts = parts[2:]
|
||||||
}
|
}
|
||||||
@@ -367,7 +452,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
|
|||||||
|
|
||||||
var targetEvent *db.Event
|
var targetEvent *db.Event
|
||||||
for i := range events {
|
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 {
|
if t == timeStr {
|
||||||
targetEvent = &events[i]
|
targetEvent = &events[i]
|
||||||
break
|
break
|
||||||
@@ -378,7 +463,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
note := strings.Join(noteParts, " ")
|
note := SanitizeNote(strings.Join(noteParts, " "))
|
||||||
if err := h.DB.SetEventNote(targetEvent.ID, note); err != nil {
|
if err := h.DB.SetEventNote(targetEvent.ID, note); err != nil {
|
||||||
h.sendText(ctx, msg.Chat.ID, "Error saving note")
|
h.sendText(ctx, msg.Chat.ID, "Error saving note")
|
||||||
return
|
return
|
||||||
@@ -470,8 +555,15 @@ func mainKeyboard() models.InlineKeyboardMarkup {
|
|||||||
{
|
{
|
||||||
{Text: "Report", CallbackData: "report"},
|
{Text: "Report", CallbackData: "report"},
|
||||||
{Text: "Export", CallbackData: "export"},
|
{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"},
|
{Text: "Settings", CallbackData: "settings"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -489,8 +581,8 @@ func backKeyboard() models.InlineKeyboardMarkup {
|
|||||||
|
|
||||||
// formatDuration formats seconds as a human-readable duration (e.g. "7h 30m").
|
// formatDuration formats seconds as a human-readable duration (e.g. "7h 30m").
|
||||||
func formatDuration(seconds int64) string {
|
func formatDuration(seconds int64) string {
|
||||||
hours := seconds / 3600
|
hours := seconds / secondsPerHour
|
||||||
mins := (seconds % 3600) / 60
|
mins := (seconds % secondsPerHour) / 60
|
||||||
if hours > 0 {
|
if hours > 0 {
|
||||||
return fmt.Sprintf("%dh %dm", hours, mins)
|
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)
|
last, err := h.DB.GetLastEvent(user.ID)
|
||||||
if err == nil && last != nil && last.EventType == "in" {
|
if err == nil && last != nil && last.EventType == "in" {
|
||||||
loc := loadLocation(user.Timezone)
|
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)
|
text += fmt.Sprintf("\nStatus: working since %s", t)
|
||||||
} else {
|
} else {
|
||||||
text += "\nStatus: not working"
|
text += "\nStatus: not working"
|
||||||
@@ -49,14 +49,33 @@ func (h *Handler) buildStatusText(chatID int64) string {
|
|||||||
text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds))
|
text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Salary info if configured
|
||||||
|
st, _ := h.DB.GetOrCreateSettings(user.ID)
|
||||||
|
if st.HourlyRate > 0 {
|
||||||
|
if events, err := h.DB.EventsForDayByDayID(day.ID); err == nil && len(events) > 0 {
|
||||||
loc := loadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
weekTotal := h.computeWeekTotals(user, loc)
|
now := time.Now().In(loc)
|
||||||
if weekTotal > 0 {
|
y, m, d := now.Date()
|
||||||
text += fmt.Sprintf("\nWeek: %s", formatDuration(weekTotal))
|
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 {
|
// RPG info if enabled
|
||||||
text += fmt.Sprintf("\nBreak threshold: %dm", day.MinBreakThreshold/60)
|
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:"
|
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)
|
y, m, d := parseGregorianDateKey(day.Date)
|
||||||
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
|
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
|
||||||
now := time.Now().In(loc)
|
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()
|
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
|
||||||
if day.Date == todayStr {
|
if day.Date == todayStr {
|
||||||
dayEnd = now.Unix()
|
dayEnd = now.Unix()
|
||||||
@@ -101,7 +120,7 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event
|
|||||||
lines += "\n\n"
|
lines += "\n\n"
|
||||||
|
|
||||||
for _, e := range events {
|
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"
|
label := "IN"
|
||||||
if e.EventType == "out" {
|
if e.EventType == "out" {
|
||||||
label = "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.TotalSeconds),
|
||||||
formatDuration(totals.BreakSeconds))
|
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
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +163,7 @@ func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int6
|
|||||||
loc = time.UTC
|
loc = time.UTC
|
||||||
}
|
}
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
today := now.Format("2006-01-02")
|
today := now.Format(DateLayout)
|
||||||
|
|
||||||
day, err := h.DB.GetOrCreateDay(user.ID, today)
|
day, err := h.DB.GetOrCreateDay(user.ID, today)
|
||||||
if err != nil {
|
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.
|
// handleSetReportTime parses a HH:MM argument and updates the user's report time.
|
||||||
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message) {
|
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message) {
|
||||||
args := ""
|
args := ""
|
||||||
if len(msg.Text) > 16 { // "/setreporttime " is 15 chars
|
if len(msg.Text) > 16 {
|
||||||
args = strings.TrimSpace(msg.Text[15:])
|
args = strings.TrimSpace(msg.Text[15:])
|
||||||
}
|
}
|
||||||
if args == "" {
|
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")
|
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
if len(args) != 5 || args[2] != ':' {
|
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")
|
h.sendText(ctx, msg.Chat.ID, "Error updating report time")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.ReportTime = args
|
st, err := h.DB.GetOrCreateSettings(user.ID)
|
||||||
if err := h.DB.UpdateUser(user); err != nil {
|
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")
|
h.sendText(ctx, msg.Chat.ID, "Error updating report time")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,12 @@ func (h *Handler) handleAccentMsg(ctx context.Context, msg *models.Message) {
|
|||||||
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
||||||
return
|
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)
|
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,20 +103,23 @@ func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now().In(loc)
|
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.
|
// getTodayBreakThreshold returns today's break threshold in seconds for the given chat.
|
||||||
func (h *Handler) getTodayBreakThreshold(chatID int64) int64 {
|
func (h *Handler) getTodayBreakThreshold(chatID int64) int64 {
|
||||||
user, err := h.getOrCreateUser(chatID)
|
user, err := h.getOrCreateUser(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 300
|
return defaultBreakThreshold
|
||||||
}
|
}
|
||||||
now := time.Now()
|
loc, err := time.LoadLocation(user.Timezone)
|
||||||
date := now.Format("2006-01-02")
|
if err != nil {
|
||||||
|
loc = time.UTC
|
||||||
|
}
|
||||||
|
date := time.Now().In(loc).Format(DateLayout)
|
||||||
day, err := h.DB.GetDay(user.ID, date)
|
day, err := h.DB.GetDay(user.ID, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 300
|
return defaultBreakThreshold
|
||||||
}
|
}
|
||||||
return day.MinBreakThreshold
|
return day.MinBreakThreshold
|
||||||
}
|
}
|
||||||
@@ -133,7 +147,7 @@ func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
date := now.Format("2006-01-02")
|
date := now.Format(DateLayout)
|
||||||
day, err := h.DB.GetOrCreateDay(user.ID, date)
|
day, err := h.DB.GetOrCreateDay(user.ID, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.sendText(ctx, msg.Chat.ID, "Error updating break threshold")
|
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.
|
// settingsCallback shows the settings inline menu.
|
||||||
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
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)
|
user, err := h.getOrCreateUser(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
st, err := h.DB.GetOrCreateSettings(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
bt := h.getTodayBreakThreshold(chatID)
|
bt := h.getTodayBreakThreshold(chatID)
|
||||||
text, kb := h.buildSettingsKeyboard(user, bt)
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
h.editText(ctx, chatID, msgID, text, &kb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSettingsKeyboard returns the settings menu text and inline keyboard.
|
// 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"
|
reportStatus := "disabled"
|
||||||
if user.ReportEnabled {
|
if st.ReportEnabled {
|
||||||
reportStatus = "enabled"
|
reportStatus = "enabled"
|
||||||
}
|
}
|
||||||
|
rpgStatus := "disabled"
|
||||||
|
if st.RPGEnabled {
|
||||||
|
rpgStatus = "enabled"
|
||||||
|
}
|
||||||
|
leagueStatus := "disabled"
|
||||||
|
if st.LeagueOptIn {
|
||||||
|
leagueStatus = "on"
|
||||||
|
}
|
||||||
rows := [][]models.InlineKeyboardButton{
|
rows := [][]models.InlineKeyboardButton{
|
||||||
{{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}},
|
{{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("Calendar: %s", user.Calendar), CallbackData: "caltype"}},
|
||||||
{{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"}},
|
{{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: 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"}},
|
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||||
}
|
}
|
||||||
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
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).
|
// accentCallback shows the accent color picker (inline edit).
|
||||||
func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
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)
|
user, err := h.getOrCreateUser(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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)
|
h.editText(ctx, chatID, msgID, text, &kb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// timezoneCallback shows the current timezone and instructions to change it.
|
// timezoneCallback shows the current timezone and instructions to change it.
|
||||||
func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
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)
|
user, err := h.getOrCreateUser(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -206,7 +239,7 @@ func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildAccentKeyboard returns the accent color picker keyboard.
|
// 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 {
|
type accentOption struct {
|
||||||
name string
|
name string
|
||||||
color string
|
color string
|
||||||
@@ -220,7 +253,7 @@ func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeybo
|
|||||||
rows := [][]models.InlineKeyboardButton{}
|
rows := [][]models.InlineKeyboardButton{}
|
||||||
for _, a := range accents {
|
for _, a := range accents {
|
||||||
label := fmt.Sprintf("%s (%s)", a.name, a.color)
|
label := fmt.Sprintf("%s (%s)", a.name, a.color)
|
||||||
if a.name == user.ExportAccent {
|
if a.name == st.ExportAccent {
|
||||||
label = "> " + label
|
label = "> " + label
|
||||||
}
|
}
|
||||||
rows = append(rows, []models.InlineKeyboardButton{
|
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.
|
// selectAccent sets the user's export accent color after validation.
|
||||||
func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
|
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)
|
user, err := h.getOrCreateUser(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -244,8 +277,12 @@ func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cal
|
|||||||
if !valid[name] {
|
if !valid[name] {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.ExportAccent = name
|
st, err := h.DB.GetOrCreateSettings(user.ID)
|
||||||
if err := h.DB.UpdateUser(user); err != nil {
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.ExportAccent = name
|
||||||
|
if err := h.DB.UpdateSettings(st); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
kb := models.InlineKeyboardMarkup{
|
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.
|
// reportTimeCallback shows the current report time setting.
|
||||||
func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
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)
|
user, err := h.getOrCreateUser(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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{
|
kb := models.InlineKeyboardMarkup{
|
||||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||||
{{Text: "Back to Settings", CallbackData: "back_settings"}},
|
{{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.
|
// breakThresholdCallback shows the break threshold picker.
|
||||||
func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
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)
|
bt := h.getTodayBreakThreshold(chatID)
|
||||||
text, kb := h.buildBreakThresholdKeyboard(bt)
|
text, kb := h.buildBreakThresholdKeyboard(bt)
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
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.
|
// selectBreakThreshold saves the chosen break threshold and returns to settings.
|
||||||
func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, callbackID, valStr string) {
|
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)
|
mins, err := strconv.Atoi(valStr)
|
||||||
if err != nil || mins < 0 || mins > 480 {
|
if err != nil || mins < 0 || mins > maxBreakThresholdMins {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := h.getOrCreateUser(chatID)
|
user, err := h.getOrCreateUser(chatID)
|
||||||
@@ -319,7 +360,7 @@ func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
date := now.Format("2006-01-02")
|
date := now.Format(DateLayout)
|
||||||
day, err := h.DB.GetOrCreateDay(user.ID, date)
|
day, err := h.DB.GetOrCreateDay(user.ID, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -329,25 +370,33 @@ func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
bt := h.getTodayBreakThreshold(chatID)
|
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)
|
h.editText(ctx, chatID, msgID, text, &kb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// backToSettings returns to the main settings menu.
|
// backToSettings returns to the main settings menu.
|
||||||
func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
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)
|
user, err := h.getOrCreateUser(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
st, err := h.DB.GetOrCreateSettings(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
bt := h.getTodayBreakThreshold(chatID)
|
bt := h.getTodayBreakThreshold(chatID)
|
||||||
text, kb := h.buildSettingsKeyboard(user, bt)
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
||||||
h.editText(ctx, chatID, msgID, text, &kb)
|
h.editText(ctx, chatID, msgID, text, &kb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// calTypeCallback shows the calendar type selector.
|
// calTypeCallback shows the calendar type selector.
|
||||||
func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
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)
|
user, err := h.getOrCreateUser(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -382,7 +431,7 @@ func (h *Handler) buildCalendarKeyboard(user *db.User) (string, models.InlineKey
|
|||||||
|
|
||||||
// selectCalendar sets the user's calendar after validation.
|
// selectCalendar sets the user's calendar after validation.
|
||||||
func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
|
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}
|
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
|
||||||
if !valid[name] {
|
if !valid[name] {
|
||||||
return
|
return
|
||||||
@@ -396,6 +445,185 @@ func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, c
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
bt := h.getTodayBreakThreshold(chatID)
|
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)
|
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 {
|
if totalBreak > 0 {
|
||||||
b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak)))
|
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")
|
b.WriteString("\n\nDetails:\n")
|
||||||
for _, d := range dayDetails {
|
for _, d := range dayDetails {
|
||||||
b.WriteString(d)
|
b.WriteString(d)
|
||||||
@@ -115,39 +123,3 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
|
|||||||
}
|
}
|
||||||
return b.String()
|
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
|
package bot
|
||||||
|
|
||||||
import (
|
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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,21 +22,75 @@ type Store struct {
|
|||||||
db *sql.DB
|
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 {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
ChatID int64
|
ChatID int64
|
||||||
|
Username string
|
||||||
Timezone string
|
Timezone string
|
||||||
IsActive bool
|
IsActive bool
|
||||||
ReportEnabled bool
|
|
||||||
ReportTime string
|
|
||||||
LastReportDate *string
|
|
||||||
ExportAccent string
|
|
||||||
Calendar string
|
Calendar string
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
UpdatedAt int64
|
UpdatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserSettings holds all user-configurable preferences.
|
||||||
|
type UserSettings struct {
|
||||||
|
UserID int64
|
||||||
|
ExportAccent string
|
||||||
|
ReportEnabled bool
|
||||||
|
ReportTime string
|
||||||
|
LastReportDate string
|
||||||
|
RPGEnabled bool
|
||||||
|
LeagueOptIn bool
|
||||||
|
Currency string
|
||||||
|
HourlyRate float64
|
||||||
|
CreatedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPGStats holds the user's RPG progression data.
|
||||||
|
type RPGStats struct {
|
||||||
|
UserID int64
|
||||||
|
XP int64
|
||||||
|
Level int
|
||||||
|
CurrentStreak int
|
||||||
|
LongestStreak int
|
||||||
|
TotalWorkSeconds int64
|
||||||
|
CreatedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Achievement is a predefined achievement definition.
|
||||||
|
type Achievement struct {
|
||||||
|
ID int64
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Icon string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAchievement records when a user unlocked an achievement.
|
||||||
|
type UserAchievement struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
AchievementID int64
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
UnlockedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailyXP records XP earned per day per user.
|
||||||
|
type DailyXP struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
Date string
|
||||||
|
XPEarned int64
|
||||||
|
WorkSeconds int64
|
||||||
|
CreatedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
// WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day.
|
// WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day.
|
||||||
type WorkType struct {
|
type WorkType struct {
|
||||||
ID int64
|
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.
|
// GetOrCreateUser retrieves a user by chat ID, creating a new record if none exists.
|
||||||
func (s *Store) GetOrCreateUser(chatID int64) (*User, error) {
|
func (s *Store) GetOrCreateUser(chatID int64) (*User, error) {
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
"INSERT OR IGNORE INTO users (chat_id) VALUES (?)",
|
"INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)",
|
||||||
chatID,
|
chatID, randomUsername(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// GetUserByChatID retrieves a single user by their Telegram chat ID.
|
||||||
func (s *Store) GetUserByChatID(chatID int64) (*User, error) {
|
func (s *Store) GetUserByChatID(chatID int64) (*User, error) {
|
||||||
row := s.db.QueryRow(`
|
row := s.db.QueryRow(`
|
||||||
SELECT id, chat_id, timezone, is_active, report_enabled, report_time,
|
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
|
||||||
last_report_date, export_accent, calendar, created_at, updated_at
|
|
||||||
FROM users WHERE chat_id = ?
|
FROM users WHERE chat_id = ?
|
||||||
`, chatID)
|
`, chatID)
|
||||||
var u User
|
var u User
|
||||||
var lastReport sql.NullString
|
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime,
|
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
|
||||||
&lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if lastReport.Valid {
|
|
||||||
u.LastReportDate = &lastReport.String
|
|
||||||
}
|
|
||||||
return &u, nil
|
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 {
|
func (s *Store) UpdateUser(user *User) error {
|
||||||
_, err := s.db.Exec(
|
_, 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=?",
|
"UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?",
|
||||||
user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.Calendar, user.ID,
|
user.Username, user.Timezone, user.IsActive, 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,
|
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -400,8 +464,7 @@ func (s *Store) MarkReportSent(userID int64, date string) error {
|
|||||||
// GetAllUsers returns all users in the database.
|
// GetAllUsers returns all users in the database.
|
||||||
func (s *Store) GetAllUsers() ([]User, error) {
|
func (s *Store) GetAllUsers() ([]User, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT id, chat_id, timezone, is_active, report_enabled, report_time,
|
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
|
||||||
last_report_date, export_accent, calendar, created_at, updated_at
|
|
||||||
FROM users
|
FROM users
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -412,21 +475,289 @@ func (s *Store) GetAllUsers() ([]User, error) {
|
|||||||
var users []User
|
var users []User
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u User
|
var u User
|
||||||
var lastReport sql.NullString
|
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime,
|
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
|
||||||
&lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if lastReport.Valid {
|
|
||||||
u.LastReportDate = &lastReport.String
|
|
||||||
}
|
|
||||||
users = append(users, u)
|
users = append(users, u)
|
||||||
}
|
}
|
||||||
return users, rows.Err()
|
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.
|
// GetWorkTypes returns all work types ordered by ID.
|
||||||
func (s *Store) GetWorkTypes() ([]WorkType, error) {
|
func (s *Store) GetWorkTypes() ([]WorkType, error) {
|
||||||
rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")
|
rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")
|
||||||
|
|||||||
Reference in New Issue
Block a user