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:
24
internal/domain/burnout.go
Normal file
24
internal/domain/burnout.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ComputeTrendFromAvgs is a pure version of the trend computation for testing.
|
||||
func ComputeTrendFromAvgs(recentAvg, olderAvg float64) (pts int, direction string) {
|
||||
if olderAvg < 1 {
|
||||
if recentAvg > 0 {
|
||||
return 15, "new"
|
||||
}
|
||||
return 5, "stable"
|
||||
}
|
||||
changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100)
|
||||
if recentAvg > olderAvg*1.2 {
|
||||
return 15, fmt.Sprintf("+%.0f%%", changePct)
|
||||
}
|
||||
if olderAvg > recentAvg*1.2 {
|
||||
return 0, fmt.Sprintf("%.0f%%", changePct)
|
||||
}
|
||||
return 5, "stable"
|
||||
}
|
||||
19
internal/domain/constants.go
Normal file
19
internal/domain/constants.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
DateLayout = "2006-01-02"
|
||||
TimeLayout = "15:04"
|
||||
SecondsPerHour = 3600
|
||||
SecondsPerDay = 86400
|
||||
DefaultBreakThreshold = 300
|
||||
MaxBreakThresholdMins = 480
|
||||
StreakBonusCap = 500
|
||||
XPCurveMultiplier = 50
|
||||
SalaryRoundFactor = 100
|
||||
MaxHourlyRate = 999999
|
||||
RateLimitInterval = 1 * time.Second
|
||||
MaxNoteLength = 500
|
||||
MaxUsernameLength = 64
|
||||
)
|
||||
326
internal/domain/dateutil.go
Normal file
326
internal/domain/dateutil.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package domain
|
||||
|
||||
import "fmt"
|
||||
|
||||
var GregMonthDays = []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
|
||||
var JalaliMonthDays = []int{31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29}
|
||||
|
||||
var GregMonthNames = []string{
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
}
|
||||
var JalaliMonthNames = []string{
|
||||
"Farvardin", "Ordibehesht", "Khordad", "Tir", "Mordad", "Shahrivar",
|
||||
"Mehr", "Aban", "Azar", "Dey", "Bahman", "Esfand",
|
||||
}
|
||||
var HijriMonthNames = []string{
|
||||
"Muharram", "Safar", "Rabi' I", "Rabi' II", "Jumada I", "Jumada II",
|
||||
"Rajab", "Sha'ban", "Ramadan", "Shawwal", "Dhu al-Qa'dah", "Dhu al-Hijjah",
|
||||
}
|
||||
|
||||
func IsGregorianLeap(year int) bool {
|
||||
return year%4 == 0 && (year%100 != 0 || year%400 == 0)
|
||||
}
|
||||
|
||||
func IsJalaliLeap(year int) bool {
|
||||
r := year % 33
|
||||
return r == 1 || r == 5 || r == 9 || r == 13 || r == 17 || r == 22 || r == 26 || r == 30
|
||||
}
|
||||
|
||||
func MonthLenGreg(year, month int) int {
|
||||
if month == 2 && IsGregorianLeap(year) {
|
||||
return 29
|
||||
}
|
||||
return GregMonthDays[month-1]
|
||||
}
|
||||
|
||||
func MonthLenJalali(year, month int) int {
|
||||
if month == 12 && IsJalaliLeap(year) {
|
||||
return 30
|
||||
}
|
||||
return JalaliMonthDays[month-1]
|
||||
}
|
||||
|
||||
func IsHijriLeap(year int) bool {
|
||||
switch year % 30 {
|
||||
case 2, 5, 7, 10, 13, 16, 18, 21, 24, 27, 29:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func MonthLenHijri(year, month int) int {
|
||||
if month == 12 && IsHijriLeap(year) {
|
||||
return 30
|
||||
}
|
||||
if month%2 == 1 {
|
||||
return 30
|
||||
}
|
||||
return 29
|
||||
}
|
||||
|
||||
const jalaliEpochJDN = 1948320
|
||||
|
||||
func GregorianToJDN(gy, gm, gd int) int {
|
||||
a := (14 - gm) / 12
|
||||
y := gy + 4800 - a
|
||||
m := gm + 12*a - 3
|
||||
return gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
|
||||
}
|
||||
|
||||
func JDNToGregorian(jdn int) (int, int, int) {
|
||||
f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38
|
||||
e := 4*f + 3
|
||||
g := (e % 1461) / 4
|
||||
h := 5*g + 2
|
||||
day := (h%153)/5 + 1
|
||||
month := ((h/153 + 2) % 12) + 1
|
||||
year := e/1461 - 4716 + (14-month)/12
|
||||
return year, month, day
|
||||
}
|
||||
|
||||
func GregorianToJalali(gy, gm, gd int) (int, int, int) {
|
||||
jdn := GregorianToJDN(gy, gm, gd)
|
||||
days := jdn - jalaliEpochJDN
|
||||
if days < 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
jy := 1
|
||||
for {
|
||||
yearDays := 365
|
||||
if IsJalaliLeap(jy) {
|
||||
yearDays = 366
|
||||
}
|
||||
if days < yearDays {
|
||||
break
|
||||
}
|
||||
days -= yearDays
|
||||
jy++
|
||||
}
|
||||
jm := 1
|
||||
for mm := 0; mm < 12; mm++ {
|
||||
md := MonthLenJalali(jy, mm+1)
|
||||
if days < md {
|
||||
break
|
||||
}
|
||||
days -= md
|
||||
jm++
|
||||
}
|
||||
jd := days + 1
|
||||
return jy, jm, jd
|
||||
}
|
||||
|
||||
func JalaliToGregorian(jy, jm, jd int) (int, int, int) {
|
||||
days := 0
|
||||
for y := 1; y < jy; y++ {
|
||||
if IsJalaliLeap(y) {
|
||||
days += 366
|
||||
} else {
|
||||
days += 365
|
||||
}
|
||||
}
|
||||
for m := 1; m < jm; m++ {
|
||||
days += MonthLenJalali(jy, m)
|
||||
}
|
||||
days += jd - 1
|
||||
jdn := jalaliEpochJDN + days
|
||||
return JDNToGregorian(jdn)
|
||||
}
|
||||
|
||||
func HijriToGregorian(hy, hm, hd int) (int, int, int) {
|
||||
days := (hy-1)*354 + (hy-1)*11/30
|
||||
for m := 1; m < hm; m++ {
|
||||
days += MonthLenHijri(hy, m)
|
||||
}
|
||||
days += hd - 1
|
||||
jdn := 1948440 + days
|
||||
f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38
|
||||
e := 4*f + 3
|
||||
g := (e % 1461) / 4
|
||||
h := 5*g + 2
|
||||
day := (h%153)/5 + 1
|
||||
month := ((h/153 + 2) % 12) + 1
|
||||
year := e/1461 - 4716 + (14-month)/12
|
||||
return year, month, day
|
||||
}
|
||||
|
||||
func GregorianToHijri(gy, gm, gd int) (int, int, int) {
|
||||
jdn := GregorianToJDN(gy, gm, gd)
|
||||
days := jdn - 1948440
|
||||
hy := 1
|
||||
for {
|
||||
yearDays := 354
|
||||
if IsHijriLeap(hy) {
|
||||
yearDays = 355
|
||||
}
|
||||
if days < yearDays {
|
||||
break
|
||||
}
|
||||
days -= yearDays
|
||||
hy++
|
||||
}
|
||||
hm := 1
|
||||
for mm := 1; mm <= 12; mm++ {
|
||||
md := MonthLenHijri(hy, mm)
|
||||
if days < md {
|
||||
break
|
||||
}
|
||||
days -= md
|
||||
hm++
|
||||
}
|
||||
hd := days + 1
|
||||
return hy, hm, hd
|
||||
}
|
||||
|
||||
func DayOfWeekGregorian(gy, gm, gd int) int {
|
||||
if gm < 3 {
|
||||
gm += 12
|
||||
gy--
|
||||
}
|
||||
return (gd + (13*(gm+1))/5 + gy + gy/4 - gy/100 + gy/400) % 7
|
||||
}
|
||||
|
||||
type CalDay struct {
|
||||
DayNum int
|
||||
Date string
|
||||
}
|
||||
|
||||
type CalMonth struct {
|
||||
Title string
|
||||
WeekDays []string
|
||||
Days []CalDay
|
||||
}
|
||||
|
||||
func BuildCalendarMonth(cal string, year, month int) CalMonth {
|
||||
var title string
|
||||
var weekDays []string
|
||||
var totalDays int
|
||||
|
||||
if cal == "jalali" {
|
||||
title = fmt.Sprintf("%s %d", JalaliMonthNames[month-1], year)
|
||||
weekDays = []string{"Sh", "Ye", "Do", "Se", "Ch", "Pa", "Jo"}
|
||||
totalDays = MonthLenJalali(year, month)
|
||||
gy, gm, gd := JalaliToGregorian(year, month, 1)
|
||||
dow := DayOfWeekGregorian(gy, gm, gd)
|
||||
startOffset := dow
|
||||
var days []CalDay
|
||||
for i := 0; i < startOffset; i++ {
|
||||
days = append(days, CalDay{DayNum: 0, Date: ""})
|
||||
}
|
||||
for d := 1; d <= totalDays; d++ {
|
||||
gy, gm, gd = JalaliToGregorian(year, month, d)
|
||||
days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)})
|
||||
}
|
||||
return CalMonth{Title: title, WeekDays: weekDays, Days: days}
|
||||
}
|
||||
|
||||
if cal == "hijri" {
|
||||
title = fmt.Sprintf("%s %d", HijriMonthNames[month-1], year)
|
||||
weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}
|
||||
totalDays = MonthLenHijri(year, month)
|
||||
gy, gm, gd := HijriToGregorian(year, month, 1)
|
||||
dow := DayOfWeekGregorian(gy, gm, gd)
|
||||
startOffset := (dow + 5) % 7
|
||||
var days []CalDay
|
||||
for i := 0; i < startOffset; i++ {
|
||||
days = append(days, CalDay{DayNum: 0, Date: ""})
|
||||
}
|
||||
for d := 1; d <= totalDays; d++ {
|
||||
gy, gm, gd = HijriToGregorian(year, month, d)
|
||||
days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)})
|
||||
}
|
||||
return CalMonth{Title: title, WeekDays: weekDays, Days: days}
|
||||
}
|
||||
|
||||
title = fmt.Sprintf("%s %d", GregMonthNames[month-1], year)
|
||||
weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}
|
||||
totalDays = MonthLenGreg(year, month)
|
||||
dow := DayOfWeekGregorian(year, month, 1)
|
||||
startOffset := (dow + 5) % 7
|
||||
var days []CalDay
|
||||
for i := 0; i < startOffset; i++ {
|
||||
days = append(days, CalDay{DayNum: 0, Date: ""})
|
||||
}
|
||||
for d := 1; d <= totalDays; d++ {
|
||||
days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", year, month, d)})
|
||||
}
|
||||
return CalMonth{Title: title, WeekDays: weekDays, Days: days}
|
||||
}
|
||||
|
||||
func ParseGregorianDateKey(key string) (int, int, int) {
|
||||
var y, m, d int
|
||||
fmt.Sscanf(key, "%04d-%02d-%02d", &y, &m, &d)
|
||||
return y, m, d
|
||||
}
|
||||
|
||||
func FormatDateForCalendar(date, calType string) string {
|
||||
y, m, d := ParseGregorianDateKey(date)
|
||||
switch calType {
|
||||
case "jalali":
|
||||
jy, jm, jd := GregorianToJalali(y, m, d)
|
||||
return fmt.Sprintf("%04d-%02d-%02d", jy, jm, jd)
|
||||
case "hijri":
|
||||
hy, hm, hd := GregorianToHijri(y, m, d)
|
||||
return fmt.Sprintf("%04d-%02d-%02d", hy, hm, hd)
|
||||
default:
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
func UserDateToGregorian(dateStr, cal string) string {
|
||||
y, m, d := ParseGregorianDateKey(dateStr)
|
||||
switch cal {
|
||||
case "jalali":
|
||||
y, m, d = JalaliToGregorian(y, m, d)
|
||||
case "hijri":
|
||||
y, m, d = HijriToGregorian(y, m, d)
|
||||
}
|
||||
return fmt.Sprintf("%04d-%02d-%02d", y, m, d)
|
||||
}
|
||||
|
||||
func MonthGregorianRange(cal string, year, month int) (start, end string) {
|
||||
switch cal {
|
||||
case "jalali":
|
||||
gy, gm, gd := JalaliToGregorian(year, month, 1)
|
||||
start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
|
||||
lastDay := MonthLenJalali(year, month)
|
||||
gy, gm, gd = JalaliToGregorian(year, month, lastDay)
|
||||
end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
|
||||
case "hijri":
|
||||
gy, gm, gd := HijriToGregorian(year, month, 1)
|
||||
start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
|
||||
lastDay := MonthLenHijri(year, month)
|
||||
gy, gm, gd = HijriToGregorian(year, month, lastDay)
|
||||
end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
|
||||
default:
|
||||
start = fmt.Sprintf("%04d-%02d-%02d", year, month, 1)
|
||||
lastDay := MonthLenGreg(year, month)
|
||||
end = fmt.Sprintf("%04d-%02d-%02d", year, month, lastDay)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func FormatMonthTitle(cal string, year, month int) string {
|
||||
switch cal {
|
||||
case "jalali":
|
||||
return fmt.Sprintf("%s %d", JalaliMonthNames[month-1], year)
|
||||
case "hijri":
|
||||
return fmt.Sprintf("%s %d", HijriMonthNames[month-1], year)
|
||||
default:
|
||||
return fmt.Sprintf("%s %d", GregMonthNames[month-1], year)
|
||||
}
|
||||
}
|
||||
|
||||
func NavMonth(year, month int) (prevY, prevM, nextY, nextM int) {
|
||||
prevY, prevM = year, month-1
|
||||
if prevM < 1 {
|
||||
prevM = 12
|
||||
prevY--
|
||||
}
|
||||
nextY, nextM = year, month+1
|
||||
if nextM > 12 {
|
||||
nextM = 1
|
||||
nextY++
|
||||
}
|
||||
return
|
||||
}
|
||||
238
internal/domain/dateutil_test.go
Normal file
238
internal/domain/dateutil_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGregorianToJDN_RoundTrip(t *testing.T) {
|
||||
cases := []struct{ y, m, d int }{
|
||||
{2024, 1, 1},
|
||||
{2024, 12, 31},
|
||||
{1970, 1, 1},
|
||||
{1, 1, 1},
|
||||
{2024, 2, 29},
|
||||
{1900, 2, 28},
|
||||
{2000, 2, 29},
|
||||
}
|
||||
for _, c := range cases {
|
||||
jdn := GregorianToJDN(c.y, c.m, c.d)
|
||||
y, m, d := JDNToGregorian(jdn)
|
||||
if y != c.y || m != c.m || d != c.d {
|
||||
t.Errorf("round-trip %04d-%02d-%02d -> JDN %d -> %04d-%02d-%02d", c.y, c.m, c.d, jdn, y, m, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGregorianToJalali_RoundTrip(t *testing.T) {
|
||||
cases := []struct{ gy, gm, gd int }{
|
||||
{2024, 3, 20},
|
||||
{2024, 3, 21},
|
||||
{2024, 9, 22},
|
||||
{2025, 3, 20},
|
||||
{1979, 2, 11},
|
||||
{622, 3, 22},
|
||||
{2024, 12, 31},
|
||||
{2024, 6, 21},
|
||||
{2024, 1, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
jy, jm, jd := GregorianToJalali(c.gy, c.gm, c.gd)
|
||||
if jy == 0 {
|
||||
t.Errorf("GregorianToJalali(%d, %d, %d) returned 0", c.gy, c.gm, c.gd)
|
||||
continue
|
||||
}
|
||||
gy2, gm2, gd2 := JalaliToGregorian(jy, jm, jd)
|
||||
if gy2 != c.gy || gm2 != c.gm || gd2 != c.gd {
|
||||
t.Errorf("round-trip Gregorian %04d-%02d-%02d -> Jalali %04d-%02d-%02d -> Gregorian %04d-%02d-%02d",
|
||||
c.gy, c.gm, c.gd, jy, jm, jd, gy2, gm2, gd2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGregorianToHijri_RoundTrip(t *testing.T) {
|
||||
cases := []struct{ gy, gm, gd int }{
|
||||
{2024, 3, 21},
|
||||
{2024, 1, 1},
|
||||
{2024, 12, 31},
|
||||
{1979, 2, 11},
|
||||
{622, 7, 16},
|
||||
{2024, 7, 7},
|
||||
{2024, 6, 21},
|
||||
{2000, 1, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
hy, hm, hd := GregorianToHijri(c.gy, c.gm, c.gd)
|
||||
if hy == 0 {
|
||||
t.Errorf("GregorianToHijri(%d, %d, %d) returned 0", c.gy, c.gm, c.gd)
|
||||
continue
|
||||
}
|
||||
gy2, gm2, gd2 := HijriToGregorian(hy, hm, hd)
|
||||
diff := (gy2-c.gy)*365 + (gm2-c.gm)*30 + (gd2 - c.gd)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 1 {
|
||||
t.Errorf("round-trip off by %d days: Gregorian %04d-%02d-%02d -> Hijri %04d-%02d-%02d -> Gregorian %04d-%02d-%02d",
|
||||
diff, c.gy, c.gm, c.gd, hy, hm, hd, gy2, gm2, gd2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLeapYear(t *testing.T) {
|
||||
gregLeaps := map[int]bool{
|
||||
2000: true, 2020: true, 2024: true, 1900: false, 2100: false, 2023: false,
|
||||
}
|
||||
for y, want := range gregLeaps {
|
||||
if got := IsGregorianLeap(y); got != want {
|
||||
t.Errorf("IsGregorianLeap(%d) = %v, want %v", y, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
jalaliLeaps := map[int]bool{
|
||||
1: true, 5: true, 9: true, 13: true, 17: true, 22: true, 26: true, 30: true,
|
||||
2: false, 33: false, 1403: true,
|
||||
}
|
||||
for y, want := range jalaliLeaps {
|
||||
if got := IsJalaliLeap(y); got != want {
|
||||
t.Errorf("IsJalaliLeap(%d) = %v, want %v", y, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
hijriLeaps := map[int]bool{
|
||||
2: true, 5: true, 7: true, 10: true, 13: true, 16: true,
|
||||
18: true, 21: true, 24: true, 27: true, 29: true,
|
||||
1: false, 3: false, 30: false,
|
||||
}
|
||||
for y, want := range hijriLeaps {
|
||||
if got := IsHijriLeap(y); got != want {
|
||||
t.Errorf("IsHijriLeap(%d) = %v, want %v", y, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthLengths(t *testing.T) {
|
||||
if got := MonthLenGreg(2024, 2); got != 29 {
|
||||
t.Errorf("MonthLenGreg(2024, 2) = %d, want 29", got)
|
||||
}
|
||||
if got := MonthLenGreg(2023, 2); got != 28 {
|
||||
t.Errorf("MonthLenGreg(2023, 2) = %d, want 28", got)
|
||||
}
|
||||
if got := MonthLenGreg(2024, 1); got != 31 {
|
||||
t.Errorf("MonthLenGreg(2024, 1) = %d, want 31", got)
|
||||
}
|
||||
|
||||
if got := MonthLenJalali(1, 12); got != 30 {
|
||||
t.Errorf("MonthLenJalali(1, 12) = %d, want 30 (leap)", got)
|
||||
}
|
||||
if got := MonthLenJalali(2, 12); got != 29 {
|
||||
t.Errorf("MonthLenJalali(2, 12) = %d, want 29 (non-leap)", got)
|
||||
}
|
||||
if got := MonthLenJalali(1403, 1); got != 31 {
|
||||
t.Errorf("MonthLenJalali(1403, 1) = %d, want 31", got)
|
||||
}
|
||||
|
||||
if got := MonthLenHijri(1, 1); got != 30 {
|
||||
t.Errorf("MonthLenHijri(1, 1) = %d, want 30 (odd month)", got)
|
||||
}
|
||||
if got := MonthLenHijri(1, 2); got != 29 {
|
||||
t.Errorf("MonthLenHijri(1, 2) = %d, want 29 (even non-leap)", got)
|
||||
}
|
||||
if got := MonthLenHijri(2, 12); got != 30 {
|
||||
t.Errorf("MonthLenHijri(2, 12) = %d, want 30 (leap year, month 12)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGregorianDateKey(t *testing.T) {
|
||||
y, m, d := ParseGregorianDateKey("2024-03-21")
|
||||
if y != 2024 || m != 3 || d != 21 {
|
||||
t.Errorf("ParseGregorianDateKey got %d-%d-%d, want 2024-3-21", y, m, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDateForCalendar(t *testing.T) {
|
||||
if got := FormatDateForCalendar("2024-03-21", "gregorian"); got != "2024-03-21" {
|
||||
t.Errorf("FormatDateForCalendar gregorian: got %s", got)
|
||||
}
|
||||
if got := FormatDateForCalendar("2024-03-21", "jalali"); got != "1403-01-02" {
|
||||
t.Errorf("FormatDateForCalendar jalali: got %s, want 1403-01-02", got)
|
||||
}
|
||||
if got := FormatDateForCalendar("2024-03-21", "hijri"); got != "1445-09-11" {
|
||||
t.Errorf("FormatDateForCalendar hijri: got %s, want 1445-09-11", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDateToGregorian(t *testing.T) {
|
||||
if got := UserDateToGregorian("1403-01-02", "jalali"); got != "2024-03-21" {
|
||||
t.Errorf("UserDateToGregorian jalali: got %s, want 2024-03-21", got)
|
||||
}
|
||||
if got := UserDateToGregorian("1445-09-11", "hijri"); got != "2024-03-21" {
|
||||
t.Errorf("UserDateToGregorian hijri: got %s, want 2024-03-21", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthGregorianRange(t *testing.T) {
|
||||
start, end := MonthGregorianRange("gregorian", 2024, 1)
|
||||
if start != "2024-01-01" || end != "2024-01-31" {
|
||||
t.Errorf("gregorian range: got %s - %s", start, end)
|
||||
}
|
||||
|
||||
start, end = MonthGregorianRange("jalali", 1403, 1)
|
||||
if start != "2024-03-20" || end != "2024-04-19" {
|
||||
t.Errorf("jalali range: got %s - %s", start, end)
|
||||
}
|
||||
|
||||
start, end = MonthGregorianRange("hijri", 1445, 9)
|
||||
if start != "2024-03-11" || end != "2024-04-09" {
|
||||
t.Errorf("hijri range: got %s - %s", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarMonth(t *testing.T) {
|
||||
cm := BuildCalendarMonth("gregorian", 2024, 1)
|
||||
if cm.Title != "January 2024" {
|
||||
t.Errorf("title: got %q", cm.Title)
|
||||
}
|
||||
if len(cm.WeekDays) != 7 {
|
||||
t.Errorf("weekDays count: %d", len(cm.WeekDays))
|
||||
}
|
||||
if len(cm.Days) < 28 {
|
||||
t.Errorf("too few days: %d", len(cm.Days))
|
||||
}
|
||||
|
||||
cm2 := BuildCalendarMonth("jalali", 1403, 1)
|
||||
if cm2.Title != "Farvardin 1403" {
|
||||
t.Errorf("jalali title: got %q", cm2.Title)
|
||||
}
|
||||
if len(cm2.Days) < 31 {
|
||||
t.Errorf("too few jalali days: %d", len(cm2.Days))
|
||||
}
|
||||
|
||||
cm3 := BuildCalendarMonth("hijri", 1445, 9)
|
||||
if cm3.Title != "Ramadan 1445" {
|
||||
t.Errorf("hijri title: got %q", cm3.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMonthTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
cal string
|
||||
year, m int
|
||||
want string
|
||||
}{
|
||||
{"gregorian", 2026, 6, "June 2026"},
|
||||
{"gregorian", 2024, 1, "January 2024"},
|
||||
{"jalali", 1404, 1, "Farvardin 1404"},
|
||||
{"jalali", 1405, 12, "Esfand 1405"},
|
||||
{"hijri", 1447, 1, "Muharram 1447"},
|
||||
{"hijri", 1447, 12, "Dhu al-Hijjah 1447"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
got := FormatMonthTitle(tc.cal, tc.year, tc.m)
|
||||
if got != tc.want {
|
||||
t.Errorf("FormatMonthTitle(%q, %d, %d) = %q, want %q",
|
||||
tc.cal, tc.year, tc.m, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
76
internal/domain/rpg.go
Normal file
76
internal/domain/rpg.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// XpForLevel returns the total XP required to reach level n.
|
||||
func XpForLevel(n int) int64 {
|
||||
return int64(n) * int64(n+1) * XPCurveMultiplier
|
||||
}
|
||||
|
||||
// LevelForXP returns the level for a given total XP amount.
|
||||
func LevelForXP(xp int64) int {
|
||||
l := 1
|
||||
for XpForLevel(l) <= xp {
|
||||
l++
|
||||
}
|
||||
return l - 1
|
||||
}
|
||||
|
||||
// XpForWorkSeconds calculates XP earned for a given amount of work.
|
||||
func XpForWorkSeconds(sec int64) int64 {
|
||||
return sec
|
||||
}
|
||||
|
||||
// StreakBonus returns bonus XP for maintaining a streak.
|
||||
func StreakBonus(streak int) int64 {
|
||||
b := int64(streak) * XPCurveMultiplier
|
||||
if b > StreakBonusCap {
|
||||
b = StreakBonusCap
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ComputeStreakFromDates computes current and longest streak from a sorted list of date strings.
|
||||
func ComputeStreakFromDates(dates []string, today string) (current, longest int) {
|
||||
if len(dates) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
dateSet := make(map[string]struct{}, len(dates))
|
||||
for _, d := range dates {
|
||||
dateSet[d] = struct{}{}
|
||||
}
|
||||
|
||||
if _, ok := dateSet[today]; ok {
|
||||
current = 1
|
||||
for i := 1; ; i++ {
|
||||
t, _ := time.Parse(DateLayout, today)
|
||||
prev := t.AddDate(0, 0, -i).Format(DateLayout)
|
||||
if _, ok := dateSet[prev]; ok {
|
||||
current++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
longest = 0
|
||||
run := 1
|
||||
for i := 1; i < len(dates); i++ {
|
||||
prev, _ := time.Parse(DateLayout, dates[i-1])
|
||||
curr, _ := time.Parse(DateLayout, dates[i])
|
||||
if curr.Sub(prev).Hours() == 24 {
|
||||
run++
|
||||
} else {
|
||||
if run > longest {
|
||||
longest = run
|
||||
}
|
||||
run = 1
|
||||
}
|
||||
}
|
||||
if run > longest {
|
||||
longest = run
|
||||
}
|
||||
|
||||
return current, longest
|
||||
}
|
||||
307
internal/domain/rpg_test.go
Normal file
307
internal/domain/rpg_test.go
Normal 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
51
internal/domain/salary.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ComputeDailySalary calculates salary for a single day's work.
|
||||
func ComputeDailySalary(workSeconds int64, hourlyRate float64) float64 {
|
||||
hours := float64(workSeconds) / SecondsPerHour
|
||||
return math.Round(hours*hourlyRate*SalaryRoundFactor) / SalaryRoundFactor
|
||||
}
|
||||
|
||||
// CurrencySymbol extracts the first token as symbol, defaulting to "$".
|
||||
func CurrencySymbol(currency string) string {
|
||||
if parts := strings.Fields(currency); len(parts) >= 1 && parts[0] != "" {
|
||||
return parts[0]
|
||||
}
|
||||
return "$"
|
||||
}
|
||||
|
||||
// FormatSalary formats a salary estimate into a readable string.
|
||||
func (e SalaryEstimate) FormatSalary(currency string) string {
|
||||
sym := CurrencySymbol(currency)
|
||||
code := "USD"
|
||||
if parts := strings.Fields(currency); len(parts) >= 2 {
|
||||
code = parts[1]
|
||||
}
|
||||
return fmt.Sprintf("%s%.2f %s", sym, e.GrossEarning, code)
|
||||
}
|
||||
|
||||
// FormatSalaryShort returns just the symbol + amount.
|
||||
func (e SalaryEstimate) FormatSalaryShort(currency string) string {
|
||||
return fmt.Sprintf("%s%.2f", CurrencySymbol(currency), e.GrossEarning)
|
||||
}
|
||||
|
||||
// ParseCurrencyInput validates and normalizes currency input (symbol + code).
|
||||
func ParseCurrencyInput(s string) (string, string) {
|
||||
parts := strings.Fields(s)
|
||||
if len(parts) < 2 {
|
||||
return "", "Please provide both symbol and code.\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial"
|
||||
}
|
||||
for _, p := range parts[:2] {
|
||||
if len(p) > 10 || strings.ContainsFunc(p, unicode.IsControl) {
|
||||
return "", "Invalid currency. Use short printable strings like $ USD, T Toman, IRR Rial."
|
||||
}
|
||||
}
|
||||
return parts[0] + " " + parts[1], ""
|
||||
}
|
||||
38
internal/domain/sanitize.go
Normal file
38
internal/domain/sanitize.go
Normal 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
68
internal/domain/totals.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package domain
|
||||
|
||||
import "sort"
|
||||
|
||||
// ComputeDailyTotals calculates the total work and break time from a list of events.
|
||||
func ComputeDailyTotals(events []Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals {
|
||||
if len(events) == 0 {
|
||||
return DailyTotals{}
|
||||
}
|
||||
|
||||
sorted := make([]Event, len(events))
|
||||
copy(sorted, events)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].OccurredAt < sorted[j].OccurredAt
|
||||
})
|
||||
|
||||
var totalWork, totalBreak int64
|
||||
state := StateIdle
|
||||
var workStart, breakStart int64
|
||||
|
||||
if dayStart > 0 {
|
||||
state = StateWorking
|
||||
workStart = dayStart
|
||||
} else if sorted[0].EventType == "out" {
|
||||
state = StateOnBreak
|
||||
}
|
||||
|
||||
for _, e := range sorted {
|
||||
switch {
|
||||
case e.EventType == "in" && state == StateIdle:
|
||||
state = StateWorking
|
||||
workStart = e.OccurredAt
|
||||
case e.EventType == "in" && state == StateOnBreak:
|
||||
breakElapsed := e.OccurredAt - breakStart
|
||||
if breakElapsed >= minBreakThreshold {
|
||||
totalBreak += breakElapsed
|
||||
} else {
|
||||
totalWork += breakElapsed
|
||||
}
|
||||
state = StateWorking
|
||||
workStart = e.OccurredAt
|
||||
case e.EventType == "in" && state == StateWorking:
|
||||
case e.EventType == "out" && state == StateWorking:
|
||||
totalWork += e.OccurredAt - workStart
|
||||
state = StateOnBreak
|
||||
breakStart = e.OccurredAt
|
||||
case e.EventType == "out" && state == StateOnBreak:
|
||||
breakStart = e.OccurredAt
|
||||
case e.EventType == "out" && state == StateIdle:
|
||||
state = StateOnBreak
|
||||
breakStart = e.OccurredAt
|
||||
}
|
||||
}
|
||||
|
||||
if state == StateWorking && dayEnd > 0 {
|
||||
totalWork += dayEnd - workStart
|
||||
}
|
||||
if state == StateOnBreak && dayEnd > 0 {
|
||||
breakElapsed := dayEnd - breakStart
|
||||
if breakElapsed >= minBreakThreshold {
|
||||
totalBreak += breakElapsed
|
||||
} else {
|
||||
totalWork += breakElapsed
|
||||
}
|
||||
}
|
||||
|
||||
return DailyTotals{TotalSeconds: totalWork, BreakSeconds: totalBreak}
|
||||
}
|
||||
209
internal/domain/totals_test.go
Normal file
209
internal/domain/totals_test.go
Normal 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
188
internal/domain/types.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package domain
|
||||
|
||||
// User represents a bot user with identity, timezone, and calendar preferences.
|
||||
type User struct {
|
||||
ID int64
|
||||
ChatID int64
|
||||
Username string
|
||||
Timezone string
|
||||
IsActive bool
|
||||
Calendar string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// UserSettings holds all user-configurable preferences.
|
||||
type UserSettings struct {
|
||||
UserID int64
|
||||
ExportAccent string
|
||||
ReportEnabled bool
|
||||
ReportTime string
|
||||
LastReportDate string
|
||||
RPGEnabled bool
|
||||
LeagueOptIn bool
|
||||
Currency string
|
||||
HourlyRate float64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// RPGStats holds the user's RPG progression data.
|
||||
type RPGStats struct {
|
||||
UserID int64
|
||||
XP int64
|
||||
Level int
|
||||
CurrentStreak int
|
||||
LongestStreak int
|
||||
TotalWorkSeconds int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// Achievement is a predefined achievement definition.
|
||||
type Achievement struct {
|
||||
ID int64
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
}
|
||||
|
||||
// UserAchievement records when a user unlocked an achievement.
|
||||
type UserAchievement struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
AchievementID int64
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
UnlockedAt int64
|
||||
}
|
||||
|
||||
// DailyXP records XP earned per day per user.
|
||||
type DailyXP struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Date string
|
||||
XPEarned int64
|
||||
WorkSeconds int64
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day.
|
||||
type WorkType struct {
|
||||
ID int64
|
||||
Name string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// Day represents a single day entry for a user, including day-off status and work type.
|
||||
type Day struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Date string
|
||||
IsDayOff bool
|
||||
DayOffReason string
|
||||
CurrentWorkTypeID int64
|
||||
MinBreakThreshold int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// Event represents a single timestamped event ("in" or "out") for a user on a given day.
|
||||
type Event struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
DayID int64
|
||||
EventType string
|
||||
WorkTypeID *int64
|
||||
OccurredAt int64
|
||||
Note string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// LeagueEntry holds a user's league ranking data.
|
||||
type LeagueEntry struct {
|
||||
UserID int64
|
||||
Username string
|
||||
XP int64
|
||||
Level int
|
||||
Streak int
|
||||
TotalHours float64
|
||||
}
|
||||
|
||||
// SalaryEstimate holds a salary calculation result.
|
||||
type SalaryEstimate struct {
|
||||
Currency string
|
||||
HourlyRate float64
|
||||
WorkSeconds int64
|
||||
WorkHours float64
|
||||
GrossEarning float64
|
||||
WorkDays int
|
||||
}
|
||||
|
||||
// BurnoutLevel represents the severity of burnout indicators.
|
||||
type BurnoutLevel int
|
||||
|
||||
const (
|
||||
BurnoutHealthy BurnoutLevel = 0
|
||||
BurnoutMild BurnoutLevel = 1
|
||||
BurnoutModerate BurnoutLevel = 2
|
||||
BurnoutHigh BurnoutLevel = 3
|
||||
)
|
||||
|
||||
func (b BurnoutLevel) String() string {
|
||||
switch b {
|
||||
case BurnoutHealthy:
|
||||
return "Healthy"
|
||||
case BurnoutMild:
|
||||
return "Mild concern"
|
||||
case BurnoutModerate:
|
||||
return "Moderate concern"
|
||||
case BurnoutHigh:
|
||||
return "High concern"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// BurnoutResult holds the full burnout assessment.
|
||||
type BurnoutResult struct {
|
||||
Score int
|
||||
Level BurnoutLevel
|
||||
ConsecutivePts int
|
||||
LongHoursPts int
|
||||
BreakRatioPts int
|
||||
WeekendPts int
|
||||
TrendPts int
|
||||
LateNightPts int
|
||||
ConsecutiveDays int
|
||||
WorkSeconds int64
|
||||
BreakSeconds int64
|
||||
TrendDirection string
|
||||
}
|
||||
|
||||
// DailyTotals holds the computed total work and break seconds for a single day.
|
||||
type DailyTotals struct {
|
||||
TotalSeconds int64
|
||||
BreakSeconds int64
|
||||
}
|
||||
|
||||
// State represents the current tracking state during daily totals computation.
|
||||
type State int
|
||||
|
||||
const (
|
||||
StateIdle State = iota
|
||||
StateWorking
|
||||
StateOnBreak
|
||||
)
|
||||
|
||||
// LeagueSortOption represents a valid league sort column.
|
||||
type LeagueSortOption string
|
||||
|
||||
const (
|
||||
LeagueSortXP LeagueSortOption = "xp"
|
||||
LeagueSortLevel LeagueSortOption = "level"
|
||||
LeagueSortStreak LeagueSortOption = "streak"
|
||||
LeagueSortHours LeagueSortOption = "hours"
|
||||
)
|
||||
Reference in New Issue
Block a user