fix: resolve go vet error and dayStart logic in ComputeDailyTotals

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

View File

@@ -0,0 +1,24 @@
package domain
import (
"fmt"
"math"
)
// ComputeTrendFromAvgs is a pure version of the trend computation for testing.
func ComputeTrendFromAvgs(recentAvg, olderAvg float64) (pts int, direction string) {
if olderAvg < 1 {
if recentAvg > 0 {
return 15, "new"
}
return 5, "stable"
}
changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100)
if recentAvg > olderAvg*1.2 {
return 15, fmt.Sprintf("+%.0f%%", changePct)
}
if olderAvg > recentAvg*1.2 {
return 0, fmt.Sprintf("%.0f%%", changePct)
}
return 5, "stable"
}

View File

@@ -0,0 +1,19 @@
package domain
import "time"
const (
DateLayout = "2006-01-02"
TimeLayout = "15:04"
SecondsPerHour = 3600
SecondsPerDay = 86400
DefaultBreakThreshold = 300
MaxBreakThresholdMins = 480
StreakBonusCap = 500
XPCurveMultiplier = 50
SalaryRoundFactor = 100
MaxHourlyRate = 999999
RateLimitInterval = 1 * time.Second
MaxNoteLength = 500
MaxUsernameLength = 64
)

326
internal/domain/dateutil.go Normal file
View File

@@ -0,0 +1,326 @@
package domain
import "fmt"
var GregMonthDays = []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
var JalaliMonthDays = []int{31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29}
var GregMonthNames = []string{
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
}
var JalaliMonthNames = []string{
"Farvardin", "Ordibehesht", "Khordad", "Tir", "Mordad", "Shahrivar",
"Mehr", "Aban", "Azar", "Dey", "Bahman", "Esfand",
}
var HijriMonthNames = []string{
"Muharram", "Safar", "Rabi' I", "Rabi' II", "Jumada I", "Jumada II",
"Rajab", "Sha'ban", "Ramadan", "Shawwal", "Dhu al-Qa'dah", "Dhu al-Hijjah",
}
func IsGregorianLeap(year int) bool {
return year%4 == 0 && (year%100 != 0 || year%400 == 0)
}
func IsJalaliLeap(year int) bool {
r := year % 33
return r == 1 || r == 5 || r == 9 || r == 13 || r == 17 || r == 22 || r == 26 || r == 30
}
func MonthLenGreg(year, month int) int {
if month == 2 && IsGregorianLeap(year) {
return 29
}
return GregMonthDays[month-1]
}
func MonthLenJalali(year, month int) int {
if month == 12 && IsJalaliLeap(year) {
return 30
}
return JalaliMonthDays[month-1]
}
func IsHijriLeap(year int) bool {
switch year % 30 {
case 2, 5, 7, 10, 13, 16, 18, 21, 24, 27, 29:
return true
}
return false
}
func MonthLenHijri(year, month int) int {
if month == 12 && IsHijriLeap(year) {
return 30
}
if month%2 == 1 {
return 30
}
return 29
}
const jalaliEpochJDN = 1948320
func GregorianToJDN(gy, gm, gd int) int {
a := (14 - gm) / 12
y := gy + 4800 - a
m := gm + 12*a - 3
return gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
}
func JDNToGregorian(jdn int) (int, int, int) {
f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38
e := 4*f + 3
g := (e % 1461) / 4
h := 5*g + 2
day := (h%153)/5 + 1
month := ((h/153 + 2) % 12) + 1
year := e/1461 - 4716 + (14-month)/12
return year, month, day
}
func GregorianToJalali(gy, gm, gd int) (int, int, int) {
jdn := GregorianToJDN(gy, gm, gd)
days := jdn - jalaliEpochJDN
if days < 0 {
return 0, 0, 0
}
jy := 1
for {
yearDays := 365
if IsJalaliLeap(jy) {
yearDays = 366
}
if days < yearDays {
break
}
days -= yearDays
jy++
}
jm := 1
for mm := 0; mm < 12; mm++ {
md := MonthLenJalali(jy, mm+1)
if days < md {
break
}
days -= md
jm++
}
jd := days + 1
return jy, jm, jd
}
func JalaliToGregorian(jy, jm, jd int) (int, int, int) {
days := 0
for y := 1; y < jy; y++ {
if IsJalaliLeap(y) {
days += 366
} else {
days += 365
}
}
for m := 1; m < jm; m++ {
days += MonthLenJalali(jy, m)
}
days += jd - 1
jdn := jalaliEpochJDN + days
return JDNToGregorian(jdn)
}
func HijriToGregorian(hy, hm, hd int) (int, int, int) {
days := (hy-1)*354 + (hy-1)*11/30
for m := 1; m < hm; m++ {
days += MonthLenHijri(hy, m)
}
days += hd - 1
jdn := 1948440 + days
f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38
e := 4*f + 3
g := (e % 1461) / 4
h := 5*g + 2
day := (h%153)/5 + 1
month := ((h/153 + 2) % 12) + 1
year := e/1461 - 4716 + (14-month)/12
return year, month, day
}
func GregorianToHijri(gy, gm, gd int) (int, int, int) {
jdn := GregorianToJDN(gy, gm, gd)
days := jdn - 1948440
hy := 1
for {
yearDays := 354
if IsHijriLeap(hy) {
yearDays = 355
}
if days < yearDays {
break
}
days -= yearDays
hy++
}
hm := 1
for mm := 1; mm <= 12; mm++ {
md := MonthLenHijri(hy, mm)
if days < md {
break
}
days -= md
hm++
}
hd := days + 1
return hy, hm, hd
}
func DayOfWeekGregorian(gy, gm, gd int) int {
if gm < 3 {
gm += 12
gy--
}
return (gd + (13*(gm+1))/5 + gy + gy/4 - gy/100 + gy/400) % 7
}
type CalDay struct {
DayNum int
Date string
}
type CalMonth struct {
Title string
WeekDays []string
Days []CalDay
}
func BuildCalendarMonth(cal string, year, month int) CalMonth {
var title string
var weekDays []string
var totalDays int
if cal == "jalali" {
title = fmt.Sprintf("%s %d", JalaliMonthNames[month-1], year)
weekDays = []string{"Sh", "Ye", "Do", "Se", "Ch", "Pa", "Jo"}
totalDays = MonthLenJalali(year, month)
gy, gm, gd := JalaliToGregorian(year, month, 1)
dow := DayOfWeekGregorian(gy, gm, gd)
startOffset := dow
var days []CalDay
for i := 0; i < startOffset; i++ {
days = append(days, CalDay{DayNum: 0, Date: ""})
}
for d := 1; d <= totalDays; d++ {
gy, gm, gd = JalaliToGregorian(year, month, d)
days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)})
}
return CalMonth{Title: title, WeekDays: weekDays, Days: days}
}
if cal == "hijri" {
title = fmt.Sprintf("%s %d", HijriMonthNames[month-1], year)
weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}
totalDays = MonthLenHijri(year, month)
gy, gm, gd := HijriToGregorian(year, month, 1)
dow := DayOfWeekGregorian(gy, gm, gd)
startOffset := (dow + 5) % 7
var days []CalDay
for i := 0; i < startOffset; i++ {
days = append(days, CalDay{DayNum: 0, Date: ""})
}
for d := 1; d <= totalDays; d++ {
gy, gm, gd = HijriToGregorian(year, month, d)
days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)})
}
return CalMonth{Title: title, WeekDays: weekDays, Days: days}
}
title = fmt.Sprintf("%s %d", GregMonthNames[month-1], year)
weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}
totalDays = MonthLenGreg(year, month)
dow := DayOfWeekGregorian(year, month, 1)
startOffset := (dow + 5) % 7
var days []CalDay
for i := 0; i < startOffset; i++ {
days = append(days, CalDay{DayNum: 0, Date: ""})
}
for d := 1; d <= totalDays; d++ {
days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", year, month, d)})
}
return CalMonth{Title: title, WeekDays: weekDays, Days: days}
}
func ParseGregorianDateKey(key string) (int, int, int) {
var y, m, d int
fmt.Sscanf(key, "%04d-%02d-%02d", &y, &m, &d)
return y, m, d
}
func FormatDateForCalendar(date, calType string) string {
y, m, d := ParseGregorianDateKey(date)
switch calType {
case "jalali":
jy, jm, jd := GregorianToJalali(y, m, d)
return fmt.Sprintf("%04d-%02d-%02d", jy, jm, jd)
case "hijri":
hy, hm, hd := GregorianToHijri(y, m, d)
return fmt.Sprintf("%04d-%02d-%02d", hy, hm, hd)
default:
return date
}
}
func UserDateToGregorian(dateStr, cal string) string {
y, m, d := ParseGregorianDateKey(dateStr)
switch cal {
case "jalali":
y, m, d = JalaliToGregorian(y, m, d)
case "hijri":
y, m, d = HijriToGregorian(y, m, d)
}
return fmt.Sprintf("%04d-%02d-%02d", y, m, d)
}
func MonthGregorianRange(cal string, year, month int) (start, end string) {
switch cal {
case "jalali":
gy, gm, gd := JalaliToGregorian(year, month, 1)
start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
lastDay := MonthLenJalali(year, month)
gy, gm, gd = JalaliToGregorian(year, month, lastDay)
end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
case "hijri":
gy, gm, gd := HijriToGregorian(year, month, 1)
start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
lastDay := MonthLenHijri(year, month)
gy, gm, gd = HijriToGregorian(year, month, lastDay)
end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
default:
start = fmt.Sprintf("%04d-%02d-%02d", year, month, 1)
lastDay := MonthLenGreg(year, month)
end = fmt.Sprintf("%04d-%02d-%02d", year, month, lastDay)
}
return
}
func FormatMonthTitle(cal string, year, month int) string {
switch cal {
case "jalali":
return fmt.Sprintf("%s %d", JalaliMonthNames[month-1], year)
case "hijri":
return fmt.Sprintf("%s %d", HijriMonthNames[month-1], year)
default:
return fmt.Sprintf("%s %d", GregMonthNames[month-1], year)
}
}
func NavMonth(year, month int) (prevY, prevM, nextY, nextM int) {
prevY, prevM = year, month-1
if prevM < 1 {
prevM = 12
prevY--
}
nextY, nextM = year, month+1
if nextM > 12 {
nextM = 1
nextY++
}
return
}

View File

@@ -0,0 +1,238 @@
package domain
import (
"testing"
)
func TestGregorianToJDN_RoundTrip(t *testing.T) {
cases := []struct{ y, m, d int }{
{2024, 1, 1},
{2024, 12, 31},
{1970, 1, 1},
{1, 1, 1},
{2024, 2, 29},
{1900, 2, 28},
{2000, 2, 29},
}
for _, c := range cases {
jdn := GregorianToJDN(c.y, c.m, c.d)
y, m, d := JDNToGregorian(jdn)
if y != c.y || m != c.m || d != c.d {
t.Errorf("round-trip %04d-%02d-%02d -> JDN %d -> %04d-%02d-%02d", c.y, c.m, c.d, jdn, y, m, d)
}
}
}
func TestGregorianToJalali_RoundTrip(t *testing.T) {
cases := []struct{ gy, gm, gd int }{
{2024, 3, 20},
{2024, 3, 21},
{2024, 9, 22},
{2025, 3, 20},
{1979, 2, 11},
{622, 3, 22},
{2024, 12, 31},
{2024, 6, 21},
{2024, 1, 1},
}
for _, c := range cases {
jy, jm, jd := GregorianToJalali(c.gy, c.gm, c.gd)
if jy == 0 {
t.Errorf("GregorianToJalali(%d, %d, %d) returned 0", c.gy, c.gm, c.gd)
continue
}
gy2, gm2, gd2 := JalaliToGregorian(jy, jm, jd)
if gy2 != c.gy || gm2 != c.gm || gd2 != c.gd {
t.Errorf("round-trip Gregorian %04d-%02d-%02d -> Jalali %04d-%02d-%02d -> Gregorian %04d-%02d-%02d",
c.gy, c.gm, c.gd, jy, jm, jd, gy2, gm2, gd2)
}
}
}
func TestGregorianToHijri_RoundTrip(t *testing.T) {
cases := []struct{ gy, gm, gd int }{
{2024, 3, 21},
{2024, 1, 1},
{2024, 12, 31},
{1979, 2, 11},
{622, 7, 16},
{2024, 7, 7},
{2024, 6, 21},
{2000, 1, 1},
}
for _, c := range cases {
hy, hm, hd := GregorianToHijri(c.gy, c.gm, c.gd)
if hy == 0 {
t.Errorf("GregorianToHijri(%d, %d, %d) returned 0", c.gy, c.gm, c.gd)
continue
}
gy2, gm2, gd2 := HijriToGregorian(hy, hm, hd)
diff := (gy2-c.gy)*365 + (gm2-c.gm)*30 + (gd2 - c.gd)
if diff < 0 {
diff = -diff
}
if diff > 1 {
t.Errorf("round-trip off by %d days: Gregorian %04d-%02d-%02d -> Hijri %04d-%02d-%02d -> Gregorian %04d-%02d-%02d",
diff, c.gy, c.gm, c.gd, hy, hm, hd, gy2, gm2, gd2)
}
}
}
func TestIsLeapYear(t *testing.T) {
gregLeaps := map[int]bool{
2000: true, 2020: true, 2024: true, 1900: false, 2100: false, 2023: false,
}
for y, want := range gregLeaps {
if got := IsGregorianLeap(y); got != want {
t.Errorf("IsGregorianLeap(%d) = %v, want %v", y, got, want)
}
}
jalaliLeaps := map[int]bool{
1: true, 5: true, 9: true, 13: true, 17: true, 22: true, 26: true, 30: true,
2: false, 33: false, 1403: true,
}
for y, want := range jalaliLeaps {
if got := IsJalaliLeap(y); got != want {
t.Errorf("IsJalaliLeap(%d) = %v, want %v", y, got, want)
}
}
hijriLeaps := map[int]bool{
2: true, 5: true, 7: true, 10: true, 13: true, 16: true,
18: true, 21: true, 24: true, 27: true, 29: true,
1: false, 3: false, 30: false,
}
for y, want := range hijriLeaps {
if got := IsHijriLeap(y); got != want {
t.Errorf("IsHijriLeap(%d) = %v, want %v", y, got, want)
}
}
}
func TestMonthLengths(t *testing.T) {
if got := MonthLenGreg(2024, 2); got != 29 {
t.Errorf("MonthLenGreg(2024, 2) = %d, want 29", got)
}
if got := MonthLenGreg(2023, 2); got != 28 {
t.Errorf("MonthLenGreg(2023, 2) = %d, want 28", got)
}
if got := MonthLenGreg(2024, 1); got != 31 {
t.Errorf("MonthLenGreg(2024, 1) = %d, want 31", got)
}
if got := MonthLenJalali(1, 12); got != 30 {
t.Errorf("MonthLenJalali(1, 12) = %d, want 30 (leap)", got)
}
if got := MonthLenJalali(2, 12); got != 29 {
t.Errorf("MonthLenJalali(2, 12) = %d, want 29 (non-leap)", got)
}
if got := MonthLenJalali(1403, 1); got != 31 {
t.Errorf("MonthLenJalali(1403, 1) = %d, want 31", got)
}
if got := MonthLenHijri(1, 1); got != 30 {
t.Errorf("MonthLenHijri(1, 1) = %d, want 30 (odd month)", got)
}
if got := MonthLenHijri(1, 2); got != 29 {
t.Errorf("MonthLenHijri(1, 2) = %d, want 29 (even non-leap)", got)
}
if got := MonthLenHijri(2, 12); got != 30 {
t.Errorf("MonthLenHijri(2, 12) = %d, want 30 (leap year, month 12)", got)
}
}
func TestParseGregorianDateKey(t *testing.T) {
y, m, d := ParseGregorianDateKey("2024-03-21")
if y != 2024 || m != 3 || d != 21 {
t.Errorf("ParseGregorianDateKey got %d-%d-%d, want 2024-3-21", y, m, d)
}
}
func TestFormatDateForCalendar(t *testing.T) {
if got := FormatDateForCalendar("2024-03-21", "gregorian"); got != "2024-03-21" {
t.Errorf("FormatDateForCalendar gregorian: got %s", got)
}
if got := FormatDateForCalendar("2024-03-21", "jalali"); got != "1403-01-02" {
t.Errorf("FormatDateForCalendar jalali: got %s, want 1403-01-02", got)
}
if got := FormatDateForCalendar("2024-03-21", "hijri"); got != "1445-09-11" {
t.Errorf("FormatDateForCalendar hijri: got %s, want 1445-09-11", got)
}
}
func TestUserDateToGregorian(t *testing.T) {
if got := UserDateToGregorian("1403-01-02", "jalali"); got != "2024-03-21" {
t.Errorf("UserDateToGregorian jalali: got %s, want 2024-03-21", got)
}
if got := UserDateToGregorian("1445-09-11", "hijri"); got != "2024-03-21" {
t.Errorf("UserDateToGregorian hijri: got %s, want 2024-03-21", got)
}
}
func TestMonthGregorianRange(t *testing.T) {
start, end := MonthGregorianRange("gregorian", 2024, 1)
if start != "2024-01-01" || end != "2024-01-31" {
t.Errorf("gregorian range: got %s - %s", start, end)
}
start, end = MonthGregorianRange("jalali", 1403, 1)
if start != "2024-03-20" || end != "2024-04-19" {
t.Errorf("jalali range: got %s - %s", start, end)
}
start, end = MonthGregorianRange("hijri", 1445, 9)
if start != "2024-03-11" || end != "2024-04-09" {
t.Errorf("hijri range: got %s - %s", start, end)
}
}
func TestBuildCalendarMonth(t *testing.T) {
cm := BuildCalendarMonth("gregorian", 2024, 1)
if cm.Title != "January 2024" {
t.Errorf("title: got %q", cm.Title)
}
if len(cm.WeekDays) != 7 {
t.Errorf("weekDays count: %d", len(cm.WeekDays))
}
if len(cm.Days) < 28 {
t.Errorf("too few days: %d", len(cm.Days))
}
cm2 := BuildCalendarMonth("jalali", 1403, 1)
if cm2.Title != "Farvardin 1403" {
t.Errorf("jalali title: got %q", cm2.Title)
}
if len(cm2.Days) < 31 {
t.Errorf("too few jalali days: %d", len(cm2.Days))
}
cm3 := BuildCalendarMonth("hijri", 1445, 9)
if cm3.Title != "Ramadan 1445" {
t.Errorf("hijri title: got %q", cm3.Title)
}
}
func TestFormatMonthTitle(t *testing.T) {
tests := []struct {
cal string
year, m int
want string
}{
{"gregorian", 2026, 6, "June 2026"},
{"gregorian", 2024, 1, "January 2024"},
{"jalali", 1404, 1, "Farvardin 1404"},
{"jalali", 1405, 12, "Esfand 1405"},
{"hijri", 1447, 1, "Muharram 1447"},
{"hijri", 1447, 12, "Dhu al-Hijjah 1447"},
}
for _, tc := range tests {
t.Run(tc.want, func(t *testing.T) {
got := FormatMonthTitle(tc.cal, tc.year, tc.m)
if got != tc.want {
t.Errorf("FormatMonthTitle(%q, %d, %d) = %q, want %q",
tc.cal, tc.year, tc.m, got, tc.want)
}
})
}
}

76
internal/domain/rpg.go Normal file
View File

@@ -0,0 +1,76 @@
package domain
import "time"
// XpForLevel returns the total XP required to reach level n.
func XpForLevel(n int) int64 {
return int64(n) * int64(n+1) * XPCurveMultiplier
}
// LevelForXP returns the level for a given total XP amount.
func LevelForXP(xp int64) int {
l := 1
for XpForLevel(l) <= xp {
l++
}
return l - 1
}
// XpForWorkSeconds calculates XP earned for a given amount of work.
func XpForWorkSeconds(sec int64) int64 {
return sec
}
// StreakBonus returns bonus XP for maintaining a streak.
func StreakBonus(streak int) int64 {
b := int64(streak) * XPCurveMultiplier
if b > StreakBonusCap {
b = StreakBonusCap
}
return b
}
// ComputeStreakFromDates computes current and longest streak from a sorted list of date strings.
func ComputeStreakFromDates(dates []string, today string) (current, longest int) {
if len(dates) == 0 {
return 0, 0
}
dateSet := make(map[string]struct{}, len(dates))
for _, d := range dates {
dateSet[d] = struct{}{}
}
if _, ok := dateSet[today]; ok {
current = 1
for i := 1; ; i++ {
t, _ := time.Parse(DateLayout, today)
prev := t.AddDate(0, 0, -i).Format(DateLayout)
if _, ok := dateSet[prev]; ok {
current++
} else {
break
}
}
}
longest = 0
run := 1
for i := 1; i < len(dates); i++ {
prev, _ := time.Parse(DateLayout, dates[i-1])
curr, _ := time.Parse(DateLayout, dates[i])
if curr.Sub(prev).Hours() == 24 {
run++
} else {
if run > longest {
longest = run
}
run = 1
}
}
if run > longest {
longest = run
}
return current, longest
}

307
internal/domain/rpg_test.go Normal file
View File

@@ -0,0 +1,307 @@
package domain
import (
"fmt"
"strings"
"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", "$"},
{"\u20AC EUR", "\u20AC"},
{"\u00A3", "\u00A3"},
{"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) {
got := ComputeDailySalary(3600, 25.0)
if got != 25.0 {
t.Errorf("ComputeDailySalary(3600, 25) = %.2f, want 25.00", got)
}
got = ComputeDailySalary(9000, 20.0)
if got != 50.0 {
t.Errorf("ComputeDailySalary(9000, 20) = %.2f, want 50.00", got)
}
got = ComputeDailySalary(0, 50.0)
if got != 0 {
t.Errorf("ComputeDailySalary(0, 50) = %.2f, want 0", got)
}
got = ComputeDailySalary(3599, 10.0)
if got != 10.0 {
t.Errorf("ComputeDailySalary(3599, 10) = %.2f, want 10.00", got)
}
}
func TestSanitizeNote(t *testing.T) {
if got := SanitizeNote(" hello "); got != "hello" {
t.Errorf("SanitizeNote trim: got %q", got)
}
if got := SanitizeNote("he\x00llo\nworld\t!"); got != "hello\nworld\t!" {
t.Errorf("SanitizeNote control: got %q", got)
}
if got := SanitizeNote("line1\nline2\tindented"); got != "line1\nline2\tindented" {
t.Errorf("SanitizeNote newline/tab: got %q", got)
}
long := make([]byte, 600)
for i := range long {
long[i] = 'a'
}
got := SanitizeNote(string(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)
}
if got := SanitizeDisplayName(""); got != "" {
t.Errorf("SanitizeDisplayName empty: got %q", got)
}
}
func TestParseCurrencyInput(t *testing.T) {
cases := []struct {
input string
want string
ok bool
}{
{"$ USD", "$ USD", true},
{"T Toman", "T Toman", true},
{"IRR Rial", "IRR Rial", true},
{"\u20AC EUR", "\u20AC EUR", true},
{"", "", false},
{"$", "", false},
{" ", "", false},
{"$$$ TooLongCode", "", false},
}
for _, c := range cases {
got, errMsg := ParseCurrencyInput(c.input)
if c.ok && (got != c.want || errMsg != "") {
t.Errorf("ParseCurrencyInput(%q) = (%q, %q), want (%q, \"\")", c.input, got, errMsg, c.want)
}
if !c.ok && errMsg == "" {
t.Errorf("ParseCurrencyInput(%q) = (%q, \"\"), want error", c.input, got)
}
}
}
func TestComputeTrendFromAvgs(t *testing.T) {
cases := []struct {
recent, older float64
wantPts int
wantDirSub string
}{
{0, 0, 5, "stable"},
{100, 0, 15, "new"},
{0, 100, 0, "-100"},
{121, 100, 15, "+21"},
{100, 100, 5, "stable"},
{100, 130, 0, "-23"},
}
for _, c := range cases {
pts, dir := ComputeTrendFromAvgs(c.recent, c.older)
if pts != c.wantPts {
t.Errorf("ComputeTrendFromAvgs(%.0f, %.0f) pts = %d, want %d", c.recent, c.older, pts, c.wantPts)
}
if !strings.Contains(dir, c.wantDirSub) {
t.Errorf("ComputeTrendFromAvgs(%.0f, %.0f) dir = %q, want containing %q", c.recent, c.older, dir, c.wantDirSub)
}
}
}
func TestComputeStreakFromDates_Empty(t *testing.T) {
cur, longest := ComputeStreakFromDates(nil, "2026-06-25")
if cur != 0 || longest != 0 {
t.Errorf("empty dates: got (%d, %d), want (0, 0)", cur, longest)
}
}
func TestComputeStreakFromDates_CurrentOnly(t *testing.T) {
dates := []string{"2026-06-25", "2026-06-26"}
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
if cur != 2 {
t.Errorf("current streak = %d, want 2", cur)
}
if longest != 2 {
t.Errorf("longest streak = %d, want 2", longest)
}
}
func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) {
dates := []string{"2026-06-23", "2026-06-25", "2026-06-26"}
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
if cur != 2 {
t.Errorf("current streak = %d, want 2 (today and yesterday)", cur)
}
if longest != 2 {
t.Errorf("longest streak = %d, want 2", longest)
}
}
func TestComputeStreakFromDates_NoEventsToday(t *testing.T) {
dates := []string{"2026-06-24", "2026-06-25"}
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
if cur != 0 {
t.Errorf("current streak = %d, want 0 (no events today)", cur)
}
if longest != 2 {
t.Errorf("longest streak = %d, want 2", longest)
}
}
func TestComputeStreakFromDates_LongStreak(t *testing.T) {
dates := make([]string, 0, 10)
for d := 1; d <= 10; d++ {
dates = append(dates, fmt.Sprintf("2026-06-%02d", d))
}
cur, longest := ComputeStreakFromDates(dates, "2026-06-10")
if cur != 10 {
t.Errorf("current streak = %d, want 10", cur)
}
if longest != 10 {
t.Errorf("longest streak = %d, want 10", longest)
}
}
func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) {
dates := []string{}
for d := 1; d <= 5; d++ {
dates = append(dates, formatDateInt(2026, 6, d))
}
dates = append(dates, "2026-06-09", "2026-06-10")
cur, longest := ComputeStreakFromDates(dates, "2026-06-10")
if cur != 2 {
t.Errorf("current streak = %d, want 2", cur)
}
if longest != 5 {
t.Errorf("longest streak = %d, want 5", longest)
}
}
func formatDateInt(y, m, d int) string {
return FormatDateForCalendar(
func() string {
s := make([]byte, 10)
s[0] = byte('0' + y/1000)
s[1] = byte('0' + (y/100)%10)
s[2] = byte('0' + (y/10)%10)
s[3] = byte('0' + y%10)
s[4] = '-'
s[5] = byte('0' + m/10)
s[6] = byte('0' + m%10)
s[7] = '-'
s[8] = byte('0' + d/10)
s[9] = byte('0' + d%10)
return string(s)
}(), "gregorian")
}

51
internal/domain/salary.go Normal file
View File

@@ -0,0 +1,51 @@
package domain
import (
"fmt"
"math"
"strings"
"unicode"
)
// ComputeDailySalary calculates salary for a single day's work.
func ComputeDailySalary(workSeconds int64, hourlyRate float64) float64 {
hours := float64(workSeconds) / SecondsPerHour
return math.Round(hours*hourlyRate*SalaryRoundFactor) / SalaryRoundFactor
}
// CurrencySymbol extracts the first token as symbol, defaulting to "$".
func CurrencySymbol(currency string) string {
if parts := strings.Fields(currency); len(parts) >= 1 && parts[0] != "" {
return parts[0]
}
return "$"
}
// FormatSalary formats a salary estimate into a readable string.
func (e SalaryEstimate) FormatSalary(currency string) string {
sym := CurrencySymbol(currency)
code := "USD"
if parts := strings.Fields(currency); len(parts) >= 2 {
code = parts[1]
}
return fmt.Sprintf("%s%.2f %s", sym, e.GrossEarning, code)
}
// FormatSalaryShort returns just the symbol + amount.
func (e SalaryEstimate) FormatSalaryShort(currency string) string {
return fmt.Sprintf("%s%.2f", CurrencySymbol(currency), e.GrossEarning)
}
// ParseCurrencyInput validates and normalizes currency input (symbol + code).
func ParseCurrencyInput(s string) (string, string) {
parts := strings.Fields(s)
if len(parts) < 2 {
return "", "Please provide both symbol and code.\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial"
}
for _, p := range parts[:2] {
if len(p) > 10 || strings.ContainsFunc(p, unicode.IsControl) {
return "", "Invalid currency. Use short printable strings like $ USD, T Toman, IRR Rial."
}
}
return parts[0] + " " + parts[1], ""
}

View File

@@ -0,0 +1,38 @@
package domain
import (
"strings"
"unicode"
)
// 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)
}

68
internal/domain/totals.go Normal file
View File

@@ -0,0 +1,68 @@
package domain
import "sort"
// ComputeDailyTotals calculates the total work and break time from a list of events.
func ComputeDailyTotals(events []Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals {
if len(events) == 0 {
return DailyTotals{}
}
sorted := make([]Event, len(events))
copy(sorted, events)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].OccurredAt < sorted[j].OccurredAt
})
var totalWork, totalBreak int64
state := StateIdle
var workStart, breakStart int64
if dayStart > 0 {
state = StateWorking
workStart = dayStart
} else if sorted[0].EventType == "out" {
state = StateOnBreak
}
for _, e := range sorted {
switch {
case e.EventType == "in" && state == StateIdle:
state = StateWorking
workStart = e.OccurredAt
case e.EventType == "in" && state == StateOnBreak:
breakElapsed := e.OccurredAt - breakStart
if breakElapsed >= minBreakThreshold {
totalBreak += breakElapsed
} else {
totalWork += breakElapsed
}
state = StateWorking
workStart = e.OccurredAt
case e.EventType == "in" && state == StateWorking:
case e.EventType == "out" && state == StateWorking:
totalWork += e.OccurredAt - workStart
state = StateOnBreak
breakStart = e.OccurredAt
case e.EventType == "out" && state == StateOnBreak:
breakStart = e.OccurredAt
case e.EventType == "out" && state == StateIdle:
state = StateOnBreak
breakStart = e.OccurredAt
}
}
if state == StateWorking && dayEnd > 0 {
totalWork += dayEnd - workStart
}
if state == StateOnBreak && dayEnd > 0 {
breakElapsed := dayEnd - breakStart
if breakElapsed >= minBreakThreshold {
totalBreak += breakElapsed
} else {
totalWork += breakElapsed
}
}
return DailyTotals{TotalSeconds: totalWork, BreakSeconds: totalBreak}
}

View File

@@ -0,0 +1,209 @@
package domain
import (
"testing"
)
func mkEvent(typ string, ts int64) Event {
return Event{EventType: typ, OccurredAt: ts}
}
func TestComputeDailyTotals_Empty(t *testing.T) {
got := ComputeDailyTotals(nil, 0, 0, 0)
if got.TotalSeconds != 0 || got.BreakSeconds != 0 {
t.Errorf("empty input: got %+v", got)
}
}
func TestComputeDailyTotals_NoEvents(t *testing.T) {
got := ComputeDailyTotals([]Event{}, 0, 0, 0)
if got.TotalSeconds != 0 || got.BreakSeconds != 0 {
t.Errorf("no events: got %+v", got)
}
}
func TestComputeDailyTotals_SingleInOut(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 100 {
t.Errorf("single in/out: TotalSeconds=%d, want 100", got.TotalSeconds)
}
if got.BreakSeconds != 0 {
t.Errorf("single in/out: BreakSeconds=%d, want 0", got.BreakSeconds)
}
}
func TestComputeDailyTotals_MissingFirstIn(t *testing.T) {
events := []Event{
mkEvent("out", 200),
mkEvent("in", 300),
mkEvent("out", 400),
}
got := ComputeDailyTotals(events, 0, 100, 500)
if got.TotalSeconds != 200 {
t.Errorf("missing first in: TotalSeconds=%d, want 200", got.TotalSeconds)
}
}
func TestComputeDailyTotals_MissingLastOut(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 300),
}
got := ComputeDailyTotals(events, 0, 0, 500)
if got.TotalSeconds != 300 {
t.Errorf("missing last out: TotalSeconds=%d, want 300", got.TotalSeconds)
}
}
func TestComputeDailyTotals_BreakUnderThreshold(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 250),
mkEvent("out", 400),
}
got := ComputeDailyTotals(events, 60, 0, 0)
if got.TotalSeconds != 300 {
t.Errorf("break under threshold: TotalSeconds=%d, want 300", got.TotalSeconds)
}
if got.BreakSeconds != 0 {
t.Errorf("break under threshold: BreakSeconds=%d, want 0", got.BreakSeconds)
}
}
func TestComputeDailyTotals_BreakOverThreshold(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 300),
mkEvent("out", 400),
}
got := ComputeDailyTotals(events, 60, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("break over threshold: TotalSeconds=%d, want 200", got.TotalSeconds)
}
if got.BreakSeconds != 100 {
t.Errorf("break over threshold: BreakSeconds=%d, want 100", got.BreakSeconds)
}
}
func TestComputeDailyTotals_ConsecutiveOuts(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("out", 250),
mkEvent("in", 300),
mkEvent("out", 400),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("consecutive outs: TotalSeconds=%d, want 200", got.TotalSeconds)
}
}
func TestComputeDailyTotals_ConsecutiveIns(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("in", 150),
mkEvent("out", 300),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("consecutive ins: TotalSeconds=%d, want 200", got.TotalSeconds)
}
}
func TestComputeDailyTotals_SingleInEndOfDay(t *testing.T) {
events := []Event{
mkEvent("in", 100),
}
got := ComputeDailyTotals(events, 0, 0, 500)
if got.TotalSeconds != 400 {
t.Errorf("single in with dayEnd: TotalSeconds=%d, want 400", got.TotalSeconds)
}
}
func TestComputeDailyTotals_SingleOutStartOfDay(t *testing.T) {
events := []Event{
mkEvent("out", 200),
}
got := ComputeDailyTotals(events, 0, 100, 500)
if got.TotalSeconds != 100 {
t.Errorf("single out with dayStart: TotalSeconds=%d, want 100", got.TotalSeconds)
}
}
func TestComputeDailyTotals_ZeroThreshold(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 250),
mkEvent("out", 400),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 250 {
t.Errorf("zero threshold: TotalSeconds=%d, want 250", got.TotalSeconds)
}
if got.BreakSeconds != 50 {
t.Errorf("zero threshold: BreakSeconds=%d, want 50", got.BreakSeconds)
}
}
func TestComputeDailyTotals_LargeBreak(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 10000),
mkEvent("out", 10100),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("large break: TotalSeconds=%d, want 200", got.TotalSeconds)
}
if got.BreakSeconds != 9800 {
t.Errorf("large break: BreakSeconds=%d, want 9800", got.BreakSeconds)
}
}
func TestComputeDailyTotals_MultipleSessions(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 300),
mkEvent("out", 400),
mkEvent("in", 500),
mkEvent("out", 600),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 300 {
t.Errorf("multiple sessions: TotalSeconds=%d, want 300", got.TotalSeconds)
}
if got.BreakSeconds != 200 {
t.Errorf("multiple sessions: BreakSeconds=%d, want 200", got.BreakSeconds)
}
}
func TestComputeDailyTotals_OnlyOut(t *testing.T) {
events := []Event{
mkEvent("out", 200),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 0 {
t.Errorf("only out no dayStart: TotalSeconds=%d, want 0", got.TotalSeconds)
}
}
func TestComputeDailyTotals_OnlyIn(t *testing.T) {
events := []Event{
mkEvent("in", 100),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 0 {
t.Errorf("only in no dayEnd: TotalSeconds=%d, want 0", got.TotalSeconds)
}
}

188
internal/domain/types.go Normal file
View File

@@ -0,0 +1,188 @@
package domain
// User represents a bot user with identity, timezone, and calendar preferences.
type User struct {
ID int64
ChatID int64
Username string
Timezone string
IsActive bool
Calendar string
CreatedAt int64
UpdatedAt int64
}
// UserSettings holds all user-configurable preferences.
type UserSettings struct {
UserID int64
ExportAccent string
ReportEnabled bool
ReportTime string
LastReportDate string
RPGEnabled bool
LeagueOptIn bool
Currency string
HourlyRate float64
CreatedAt int64
UpdatedAt int64
}
// RPGStats holds the user's RPG progression data.
type RPGStats struct {
UserID int64
XP int64
Level int
CurrentStreak int
LongestStreak int
TotalWorkSeconds int64
CreatedAt int64
UpdatedAt int64
}
// Achievement is a predefined achievement definition.
type Achievement struct {
ID int64
Code string
Name string
Description string
Icon string
}
// UserAchievement records when a user unlocked an achievement.
type UserAchievement struct {
ID int64
UserID int64
AchievementID int64
Code string
Name string
Description string
UnlockedAt int64
}
// DailyXP records XP earned per day per user.
type DailyXP struct {
ID int64
UserID int64
Date string
XPEarned int64
WorkSeconds int64
CreatedAt int64
}
// WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day.
type WorkType struct {
ID int64
Name string
CreatedAt int64
}
// Day represents a single day entry for a user, including day-off status and work type.
type Day struct {
ID int64
UserID int64
Date string
IsDayOff bool
DayOffReason string
CurrentWorkTypeID int64
MinBreakThreshold int64
CreatedAt int64
UpdatedAt int64
}
// Event represents a single timestamped event ("in" or "out") for a user on a given day.
type Event struct {
ID int64
UserID int64
DayID int64
EventType string
WorkTypeID *int64
OccurredAt int64
Note string
CreatedAt int64
}
// LeagueEntry holds a user's league ranking data.
type LeagueEntry struct {
UserID int64
Username string
XP int64
Level int
Streak int
TotalHours float64
}
// SalaryEstimate holds a salary calculation result.
type SalaryEstimate struct {
Currency string
HourlyRate float64
WorkSeconds int64
WorkHours float64
GrossEarning float64
WorkDays int
}
// BurnoutLevel represents the severity of burnout indicators.
type BurnoutLevel int
const (
BurnoutHealthy BurnoutLevel = 0
BurnoutMild BurnoutLevel = 1
BurnoutModerate BurnoutLevel = 2
BurnoutHigh BurnoutLevel = 3
)
func (b BurnoutLevel) String() string {
switch b {
case BurnoutHealthy:
return "Healthy"
case BurnoutMild:
return "Mild concern"
case BurnoutModerate:
return "Moderate concern"
case BurnoutHigh:
return "High concern"
default:
return "Unknown"
}
}
// BurnoutResult holds the full burnout assessment.
type BurnoutResult struct {
Score int
Level BurnoutLevel
ConsecutivePts int
LongHoursPts int
BreakRatioPts int
WeekendPts int
TrendPts int
LateNightPts int
ConsecutiveDays int
WorkSeconds int64
BreakSeconds int64
TrendDirection string
}
// DailyTotals holds the computed total work and break seconds for a single day.
type DailyTotals struct {
TotalSeconds int64
BreakSeconds int64
}
// State represents the current tracking state during daily totals computation.
type State int
const (
StateIdle State = iota
StateWorking
StateOnBreak
)
// LeagueSortOption represents a valid league sort column.
type LeagueSortOption string
const (
LeagueSortXP LeagueSortOption = "xp"
LeagueSortLevel LeagueSortOption = "level"
LeagueSortStreak LeagueSortOption = "streak"
LeagueSortHours LeagueSortOption = "hours"
)