Compare commits

...

7 Commits

Author SHA1 Message Date
bddb920c82 fix: add missing confirmations to command handlers for consistency
All checks were successful
CI / build (push) Successful in 50s
All /setrate, /setcurrency, /settimezone, /setbreak, /setusername,
/setreporttime now send a confirmation message, matching the UI flow.
Also fixes rate confirmation to show the actual currency symbol.
2026-06-29 10:09:09 +03:30
2c1c149b13 fix: show actual currency symbol in set rate confirmation
processRateInput was passing empty string to CurrencySymbol, causing
it to default to '$' instead of the user's configured currency.
2026-06-29 10:07:12 +03:30
6f7f5878bf fix: correct daily work/break calculations and league back button
All checks were successful
CI / build (push) Successful in 38s
- ComputeDailyTotals: only assume working from dayStart when first event
  is 'out' (cross-midnight session); otherwise start idle. Fixes inflated
  work/break counts in reports, status, export, burnout, salary, and RPG.
- leagueToggleCallback: add settings back keyboard when RPG is disabled.
- webhook: add graceful HTTP server shutdown on context cancellation.
2026-06-27 23:15:12 +03:30
7dbd9fda12 feat: add /setcalendar command, store tests, and improve error handling
All checks were successful
CI / build (push) Successful in 48s
- Add /setcalendar command for calendar type parity (Settings UI)
- Add 17 store tests covering all repository methods
- Add handler tests for formatDuration, fmtHHMM, fmtDDHHMM
- Add domain tests for BurnoutLevel.String, NavMonth, FormatSalary
- Fix silent error discarding in save* helpers (log write failures)
- Fix computeAndAwardXP error handling instead of discarding
- Wrap critical store.go SQL errors with fmt.Errorf context
- Update README commands table with /setcalendar
2026-06-25 15:56:00 +03:30
d9e13cb51d chore: gitignore and untrack opencode.json
All checks were successful
CI / build (push) Successful in 33s
2026-06-25 15:10:04 +03:30
59c35305c7 docs: add Gitea Actions CI badge to README
All checks were successful
CI / build (push) Successful in 33s
2026-06-25 12:33:58 +03:30
5670b0455d feat: add delete account button in settings, implement all remaining achievements
All checks were successful
CI / build (push) Successful in 37s
- Add delete all data button with red danger style and confirmation prompt
- Add DeleteUserData repo method that wipes user from all tables in a transaction
- Add helper methods deleteAccountPrompt and deleteAccountConfirm
- Implement consistency_master, perfect_week, remote_veteran, onsite_master achievements
- Add GetDailyXPInRange and SumWorkSecondsByWorkType repo methods
- Update Dockerfile to alpine3.23, docker-compose to use env vars for proxy/paths
2026-06-25 12:27:03 +03:30
16 changed files with 1070 additions and 174 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
db.sqlite3 db.sqlite3
.env .env
.DS_Store .DS_Store
opencode.json

View File

@@ -1,4 +1,4 @@
FROM golang:1.26.4-alpine AS builder FROM golang:1.26.4-alpine3.23 AS builder
WORKDIR /src WORKDIR /src
@@ -12,7 +12,7 @@ RUN go mod download
COPY . . COPY . .
RUN go build -o /usr/local/bin/worktime-bot ./cmd/bot/ RUN go build -o /usr/local/bin/worktime-bot ./cmd/bot/
FROM alpine:3.18 FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata RUN apk add --no-cache ca-certificates tzdata

View File

@@ -1,5 +1,7 @@
# WorkTime Bot # WorkTime Bot
[![CI](https://git.db123.ir/db123/worktimeBot/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.db123.ir/db123/worktimeBot/actions/workflows/ci.yml)
A Telegram time-tracking bot with **RPG progression system**, **WorkTime League**, **salary estimation**, **burnout assessment**, and **multi-calendar support** (Gregorian, Jalali/Persian, Hijri). Clock in/out, track work types, mark day off, get daily reports, browse and edit history by month, and export monthly `.xlsx` reports. A Telegram time-tracking bot with **RPG progression system**, **WorkTime League**, **salary estimation**, **burnout assessment**, and **multi-calendar support** (Gregorian, Jalali/Persian, Hijri). Clock in/out, track work types, mark day off, get daily reports, browse and edit history by month, and export monthly `.xlsx` reports.
## Quick Start ## Quick Start
@@ -106,6 +108,7 @@ Categorize your work sessions (e.g., Remote, Onsite, Meeting). The current work
| `/edit YYYY-MM-DD` | View/edit events for a specific date | | `/edit YYYY-MM-DD` | View/edit events for a specific date |
| `/note [DATE] HH:MM <text>` | Set note for an event at a specific time | | `/note [DATE] HH:MM <text>` | Set note for an event at a specific time |
| `/settimezone <IANA>` | Set timezone (e.g. `Asia/Tehran`) | | `/settimezone <IANA>` | Set timezone (e.g. `Asia/Tehran`) |
| `/setcalendar <type>` | Set calendar type (`gregorian`, `jalali`, `hijri`) |
| `/setbreak <minutes>` | Set minimum break threshold (0-480, default 5) | | `/setbreak <minutes>` | Set minimum break threshold (0-480, default 5) |
| `/setaccent` | Select export accent color | | `/setaccent` | Select export accent color |
| `/setreporttime HH:MM` | Set daily auto-report time | | `/setreporttime HH:MM` | Set daily auto-report time |

View File

@@ -5,6 +5,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"time"
tgbot "github.com/go-telegram/bot" tgbot "github.com/go-telegram/bot"
@@ -26,10 +27,25 @@ func startWebhook(ctx context.Context, b *tgbot.Bot, h *handler.Handler, webhook
tlsCert := os.Getenv("BOT_TLS_CERT") tlsCert := os.Getenv("BOT_TLS_CERT")
tlsKey := os.Getenv("BOT_TLS_KEY") tlsKey := os.Getenv("BOT_TLS_KEY")
server := &http.Server{
Addr: listenAddr,
Handler: b.WebhookHandler(),
}
go func() {
<-ctx.Done()
slog.Info("shutting down webhook server")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
slog.Error("webhook server shutdown", "error", err)
}
}()
if tlsCert != "" && tlsKey != "" { if tlsCert != "" && tlsKey != "" {
slog.Info("starting HTTPS webhook", "addr", listenAddr) slog.Info("starting HTTPS webhook", "addr", listenAddr)
go func() { go func() {
if err := http.ListenAndServeTLS(listenAddr, tlsCert, tlsKey, b.WebhookHandler()); err != nil { if err := server.ListenAndServeTLS(tlsCert, tlsKey); err != nil && err != http.ErrServerClosed {
slog.Error("HTTPS server", "error", err) slog.Error("HTTPS server", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -37,7 +53,7 @@ func startWebhook(ctx context.Context, b *tgbot.Bot, h *handler.Handler, webhook
} else { } else {
slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr) slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr)
go func() { go func() {
if err := http.ListenAndServe(listenAddr, b.WebhookHandler()); err != nil { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("HTTP server", "error", err) slog.Error("HTTP server", "error", err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -3,16 +3,16 @@ services:
build: build:
context: . context: .
args: args:
HTTP_PROXY: http://192.168.100.2:10808 HTTP_PROXY: ${HTTP_PROXY}
HTTPS_PROXY: http://192.168.100.2:10808 HTTPS_PROXY: ${HTTPS_PROXY}
env_file: env_file:
- .env - .env
environment: environment:
- BOT_TOKEN=${BOT_TOKEN} - BOT_TOKEN=${BOT_TOKEN}
- HTTP_PROXY=${HTTP_PROXY-} - HTTP_PROXY=${HTTP_PROXY}
- HTTPS_PROXY=${HTTPS_PROXY-} - HTTPS_PROXY=${HTTPS_PROXY}
- DB_PATH=/data/db.sqlite3 - DB_PATH=${DB_PATH}
- TZ=Asia/Tehran - TZ=${TZ}
volumes: volumes:
- worktime_data:/data - worktime_data:/data

View File

@@ -157,6 +157,70 @@ func TestSanitizeNote(t *testing.T) {
} }
} }
func TestBurnoutLevelString(t *testing.T) {
cases := []struct {
level BurnoutLevel
want string
}{
{BurnoutHealthy, "Healthy"},
{BurnoutMild, "Mild concern"},
{BurnoutModerate, "Moderate concern"},
{BurnoutHigh, "High concern"},
{BurnoutLevel(99), "Unknown"},
}
for _, c := range cases {
got := c.level.String()
if got != c.want {
t.Errorf("BurnoutLevel(%d).String() = %q, want %q", c.level, got, c.want)
}
}
}
func TestNavMonth(t *testing.T) {
tests := []struct {
y, m int
prevY, prevM int
nextY, nextM int
}{
{2026, 6, 2026, 5, 2026, 7},
{2026, 1, 2025, 12, 2026, 2},
{2026, 12, 2026, 11, 2027, 1},
{2024, 2, 2024, 1, 2024, 3},
}
for _, tc := range tests {
prevY, prevM, nextY, nextM := NavMonth(tc.y, tc.m)
if prevY != tc.prevY || prevM != tc.prevM {
t.Errorf("NavMonth(%d, %d) prev = (%d, %d), want (%d, %d)", tc.y, tc.m, prevY, prevM, tc.prevY, tc.prevM)
}
if nextY != tc.nextY || nextM != tc.nextM {
t.Errorf("NavMonth(%d, %d) next = (%d, %d), want (%d, %d)", tc.y, tc.m, nextY, nextM, tc.nextY, tc.nextM)
}
}
}
func TestFormatSalary(t *testing.T) {
tests := []struct {
est SalaryEstimate
currency string
want string
wantShort string
}{
{SalaryEstimate{GrossEarning: 1000.50}, "$ USD", "$1000.50 USD", "$1000.50"},
{SalaryEstimate{GrossEarning: 0}, "$ USD", "$0.00 USD", "$0.00"},
{SalaryEstimate{GrossEarning: 2500}, "T Toman", "T2500.00 Toman", "T2500.00"},
}
for _, tc := range tests {
got := tc.est.FormatSalary(tc.currency)
if got != tc.want {
t.Errorf("FormatSalary(%q) = %q, want %q", tc.currency, got, tc.want)
}
gotShort := tc.est.FormatSalaryShort(tc.currency)
if gotShort != tc.wantShort {
t.Errorf("FormatSalaryShort(%q) = %q, want %q", tc.currency, gotShort, tc.wantShort)
}
}
}
func TestSanitizeDisplayName(t *testing.T) { func TestSanitizeDisplayName(t *testing.T) {
if got := SanitizeDisplayName(" Alice "); got != "Alice" { if got := SanitizeDisplayName(" Alice "); got != "Alice" {
t.Errorf("SanitizeDisplayName trim: got %q", got) t.Errorf("SanitizeDisplayName trim: got %q", got)

View File

@@ -18,7 +18,7 @@ func ComputeDailyTotals(events []Event, minBreakThreshold int64, dayStart, dayEn
state := StateIdle state := StateIdle
var workStart, breakStart int64 var workStart, breakStart int64
if dayStart > 0 { if dayStart > 0 && sorted[0].EventType == "out" {
state = StateWorking state = StateWorking
workStart = dayStart workStart = dayStart
} else if sorted[0].EventType == "out" { } else if sorted[0].EventType == "out" {

View File

@@ -36,7 +36,7 @@ func TestComputeDailyTotals_SingleInOut(t *testing.T) {
} }
} }
func TestComputeDailyTotals_MissingFirstIn(t *testing.T) { func TestComputeDailyTotals_DayStartWithOutFirst(t *testing.T) {
events := []Event{ events := []Event{
mkEvent("out", 200), mkEvent("out", 200),
mkEvent("in", 300), mkEvent("in", 300),
@@ -44,7 +44,39 @@ func TestComputeDailyTotals_MissingFirstIn(t *testing.T) {
} }
got := ComputeDailyTotals(events, 0, 100, 500) got := ComputeDailyTotals(events, 0, 100, 500)
if got.TotalSeconds != 200 { if got.TotalSeconds != 200 {
t.Errorf("missing first in: TotalSeconds=%d, want 200", got.TotalSeconds) t.Errorf("dayStart with out first: TotalSeconds=%d, want 200", got.TotalSeconds)
}
}
func TestComputeDailyTotals_DayStartWithInFirst(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 300),
mkEvent("out", 400),
}
got := ComputeDailyTotals(events, 0, 50, 500)
if got.TotalSeconds != 200 {
t.Errorf("dayStart with in first: TotalSeconds=%d, want 200", got.TotalSeconds)
}
if got.BreakSeconds != 200 {
t.Errorf("dayStart with in first: BreakSeconds=%d, want 200", got.BreakSeconds)
}
}
func TestComputeDailyTotals_DayStartInFirstRealistic(t *testing.T) {
events := []Event{
mkEvent("in", 33060),
mkEvent("out", 47100),
mkEvent("in", 48780),
mkEvent("out", 74460),
}
got := ComputeDailyTotals(events, 0, 0, 86399)
if got.TotalSeconds != 39720 {
t.Errorf("realistic day: TotalSeconds=%d, want 39720 (11h 02m)", got.TotalSeconds)
}
if got.BreakSeconds != 13619 {
t.Errorf("realistic day: BreakSeconds=%d, want 13619 (3h 19m)", got.BreakSeconds)
} }
} }

View File

@@ -24,6 +24,7 @@ const (
PendingRate PendingKind = "rate" PendingRate PendingKind = "rate"
PendingCurrency PendingKind = "currency" PendingCurrency PendingKind = "currency"
PendingTimezone PendingKind = "timezone" PendingTimezone PendingKind = "timezone"
PendingCalendar PendingKind = "calendar"
PendingBreak PendingKind = "break_threshold" PendingBreak PendingKind = "break_threshold"
PendingUsername PendingKind = "username" PendingUsername PendingKind = "username"
PendingReportTime PendingKind = "report_time" PendingReportTime PendingKind = "report_time"
@@ -176,6 +177,8 @@ func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, user *d
h.handleSetAccent(ctx, msg, user) h.handleSetAccent(ctx, msg, user)
case "/settimezone": case "/settimezone":
h.handleSetTimezone(ctx, msg, user) h.handleSetTimezone(ctx, msg, user)
case "/setcalendar":
h.handleSetCalendar(ctx, msg, user)
case "/note": case "/note":
h.handleNote(ctx, msg, user) h.handleNote(ctx, msg, user)
case "/summary": case "/summary":
@@ -323,6 +326,10 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
h.reportTimeCallback(ctx, chatID, msgID, cbID, user) h.reportTimeCallback(ctx, chatID, msgID, cbID, user)
case data == "breakthreshold": case data == "breakthreshold":
h.breakThresholdCallback(ctx, chatID, msgID, cbID, user) h.breakThresholdCallback(ctx, chatID, msgID, cbID, user)
case data == "deleteaccount":
h.deleteAccountPrompt(ctx, chatID, msgID, cbID, user)
case strings.HasPrefix(data, "delaccount_"):
h.deleteAccountConfirm(ctx, chatID, msgID, cbID, user)
case strings.HasPrefix(data, "league_"): case strings.HasPrefix(data, "league_"):
sortBy := data[7:] sortBy := data[7:]
h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy) h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy)
@@ -354,6 +361,8 @@ func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message,
h.processCurrencyInput(ctx, msg, user, pi, text) h.processCurrencyInput(ctx, msg, user, pi, text)
case PendingTimezone: case PendingTimezone:
h.processTimezoneInput(ctx, msg, user, pi, text) h.processTimezoneInput(ctx, msg, user, pi, text)
case PendingCalendar:
h.processCalendarInput(ctx, msg, user, pi, text)
case PendingBreak: case PendingBreak:
h.processBreakInput(ctx, msg, user, pi, text) h.processBreakInput(ctx, msg, user, pi, text)
case PendingUsername: case PendingUsername:

View File

@@ -0,0 +1,70 @@
package handler
import (
"testing"
)
func TestFormatDuration(t *testing.T) {
tests := []struct {
seconds int64
want string
}{
{0, "0m"},
{30, "0m"},
{59, "0m"},
{60, "1m"},
{3599, "59m"},
{3600, "1h 0m"},
{3661, "1h 1m"},
{7200, "2h 0m"},
{7260, "2h 1m"},
{86399, "23h 59m"},
{86400, "24h 0m"},
}
for _, tc := range tests {
got := formatDuration(tc.seconds)
if got != tc.want {
t.Errorf("formatDuration(%d) = %q, want %q", tc.seconds, got, tc.want)
}
}
}
func TestFmtHHMM(t *testing.T) {
tests := []struct {
seconds int64
want string
}{
{0, "0:00"},
{60, "0:01"},
{3600, "1:00"},
{3661, "1:01"},
{86399, "23:59"},
{86400, "24:00"},
}
for _, tc := range tests {
got := fmtHHMM(tc.seconds)
if got != tc.want {
t.Errorf("fmtHHMM(%d) = %q, want %q", tc.seconds, got, tc.want)
}
}
}
func TestFmtDDHHMM(t *testing.T) {
tests := []struct {
seconds int64
want string
}{
{0, "0:00:00"},
{60, "0:00:01"},
{3600, "0:01:00"},
{86400, "1:00:00"},
{90061, "1:01:01"},
{172800, "2:00:00"},
}
for _, tc := range tests {
got := fmtDDHHMM(tc.seconds)
if got != tc.want {
t.Errorf("fmtDDHHMM(%d) = %q, want %q", tc.seconds, got, tc.want)
}
}
}

View File

@@ -134,12 +134,15 @@ func (h *Handler) buildAchievementsList(user *domain.User) string {
} }
func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string { func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string {
text := ""
h.updateStreak(stats, user.ID, today) h.updateStreak(stats, user.ID, today)
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today) xp, leveledUp, newLevel, err := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
if err != nil {
slog.Error("compute and award xp failed", "user_id", user.ID, "error", err)
return ""
}
totalHours := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour totalHours := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours) unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel) text := fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
if leveledUp { if leveledUp {
text += "\nLevel up!" text += "\nLevel up!"
} }
@@ -241,6 +244,9 @@ func (h *Handler) checkAchievements(userID int64, stats *domain.RPGStats, workSe
h.checkHourAchievements(u, stats, totalHours) h.checkHourAchievements(u, stats, totalHours)
h.checkSessionAchievements(u, workSeconds, isWeekend) h.checkSessionAchievements(u, workSeconds, isWeekend)
h.checkLevelAchievements(u, stats) h.checkLevelAchievements(u, stats)
h.checkConsistencyAchievements(u, userID, date)
h.checkPerfectWeekAchievements(u, userID, date)
h.checkWorkTypeAchievements(u, userID)
return u.unlocked return u.unlocked
} }
@@ -334,3 +340,63 @@ func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *domain.R
u.unlock("level_50") u.unlock("level_50")
} }
} }
func (h *Handler) checkConsistencyAchievements(u *achievementUnlocker, userID int64, today string) {
t, err := time.Parse(domain.DateLayout, today)
if err != nil {
return
}
sevenDaysAgo := t.AddDate(0, 0, -7).Format(domain.DateLayout)
yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout)
records, err := h.Repo.GetDailyXPInRange(userID, sevenDaysAgo, yesterday)
if err != nil {
return
}
if len(records) < 7 {
return
}
allHaveSixHours := true
for _, r := range records {
if r.WorkSeconds < 6*domain.SecondsPerHour {
allHaveSixHours = false
break
}
}
if allHaveSixHours {
u.unlock("consistency_master")
}
}
func (h *Handler) checkPerfectWeekAchievements(u *achievementUnlocker, userID int64, today string) {
t, err := time.Parse(domain.DateLayout, today)
if err != nil {
return
}
start := t.AddDate(0, 0, -6).Format(domain.DateLayout)
days, err := h.Repo.GetUserDaysInRange(userID, start, today)
if err != nil {
return
}
if len(days) < 7 {
return
}
for _, d := range days {
if d.IsDayOff {
return
}
}
u.unlock("perfect_week")
}
func (h *Handler) checkWorkTypeAchievements(u *achievementUnlocker, userID int64) {
rem, ons, err := h.Repo.SumWorkSecondsByWorkType(userID)
if err != nil {
return
}
if rem >= 100*domain.SecondsPerHour {
u.unlock("remote_veteran")
}
if ons >= 100*domain.SecondsPerHour {
u.unlock("onsite_master")
}
}

View File

@@ -112,6 +112,7 @@ func (h *Handler) buildSettingsKeyboard(user *domain.User, st *domain.UserSettin
{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"}, {Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"},
{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"}, {Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"},
}, },
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
{{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}
@@ -143,7 +144,8 @@ func (h *Handler) leagueToggleCallback(ctx context.Context, chatID int64, msgID
return return
} }
if !st.RPGEnabled { if !st.RPGEnabled {
h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", nil) kb := settingsBackKeyboard()
h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", &kb)
return return
} }
text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League") text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League")
@@ -443,6 +445,12 @@ func (h *Handler) handleSetRate(ctx context.Context, msg *models.Message, user *
return return
} }
h.saveRate(ctx, msg.Chat.ID, user, rate) h.saveRate(ctx, msg.Chat.ID, user, rate)
st, _ := h.Repo.GetOrCreateSettings(user.ID)
sym := "$"
if st != nil && st.Currency != "" {
sym = domain.CurrencySymbol(st.Currency)
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", sym, rate))
} }
func (h *Handler) handleSetCurrency(ctx context.Context, msg *models.Message, user *domain.User) { func (h *Handler) handleSetCurrency(ctx context.Context, msg *models.Message, user *domain.User) {
@@ -458,6 +466,7 @@ func (h *Handler) handleSetCurrency(ctx context.Context, msg *models.Message, us
return return
} }
h.saveCurrency(ctx, msg.Chat.ID, user, currency) h.saveCurrency(ctx, msg.Chat.ID, user, currency)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Currency set to: %s", currency))
} }
func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message, user *domain.User) { func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message, user *domain.User) {
@@ -472,6 +481,9 @@ func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message, us
return return
} }
h.saveTimezone(ctx, msg.Chat.ID, user, args) h.saveTimezone(ctx, msg.Chat.ID, user, args)
loc := h.loadLocation(args)
now := time.Now().In(loc).Format(domain.TimeLayout)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now))
} }
func (h *Handler) handleSetBreak(ctx context.Context, msg *models.Message, user *domain.User) { func (h *Handler) handleSetBreak(ctx context.Context, msg *models.Message, user *domain.User) {
@@ -487,6 +499,7 @@ func (h *Handler) handleSetBreak(ctx context.Context, msg *models.Message, user
return return
} }
h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins) h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Break threshold set to %d minutes.", mins))
} }
func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message, user *domain.User) { func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message, user *domain.User) {
@@ -497,6 +510,7 @@ func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message, us
return return
} }
h.saveUsername(ctx, msg.Chat.ID, user, name) h.saveUsername(ctx, msg.Chat.ID, user, name)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Username set to: %s", name))
} }
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message, user *domain.User) { func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message, user *domain.User) {
@@ -513,7 +527,9 @@ func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message,
} }
if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, args); err != nil { if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, args); err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error()) h.sendText(ctx, msg.Chat.ID, err.Error())
return
} }
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Report time set to %s", args))
} }
func (h *Handler) handleSetAccent(ctx context.Context, msg *models.Message, user *domain.User) { func (h *Handler) handleSetAccent(ctx context.Context, msg *models.Message, user *domain.User) {
@@ -560,8 +576,13 @@ func (h *Handler) processRateInput(ctx context.Context, msg *models.Message, use
} }
h.saveRate(ctx, msg.Chat.ID, user, rate) h.saveRate(ctx, msg.Chat.ID, user, rate)
backKB := settingsBackKeyboard() backKB := settingsBackKeyboard()
st, _ := h.Repo.GetOrCreateSettings(user.ID)
sym := "$"
if st != nil && st.Currency != "" {
sym = domain.CurrencySymbol(st.Currency)
}
h.editText(ctx, msg.Chat.ID, pi.MsgID, h.editText(ctx, msg.Chat.ID, pi.MsgID,
fmt.Sprintf("Hourly rate set to %s%.2f", domain.CurrencySymbol(""), rate), &backKB) fmt.Sprintf("Hourly rate set to %s%.2f", sym, rate), &backKB)
} }
func (h *Handler) processCurrencyInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { func (h *Handler) processCurrencyInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
@@ -599,6 +620,48 @@ func (h *Handler) processTimezoneInput(ctx context.Context, msg *models.Message,
fmt.Sprintf("Timezone set to %s (current time: %s)", text, now.Format(domain.TimeLayout)), &backKB) fmt.Sprintf("Timezone set to %s (current time: %s)", text, now.Format(domain.TimeLayout)), &backKB)
} }
func (h *Handler) handleSetCalendar(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcalendar"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingCalendar,
fmt.Sprintf("Current calendar: %s\n\nValid options: gregorian, jalali, hijri", user.Calendar), nil)
return
}
args = strings.ToLower(args)
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
if !valid[args] {
h.sendText(ctx, msg.Chat.ID, "Invalid calendar. Valid options: gregorian, jalali, hijri")
return
}
h.saveCalendar(ctx, msg.Chat.ID, user, args)
}
func (h *Handler) processCalendarInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
text = strings.ToLower(strings.TrimSpace(text))
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
if !valid[text] {
h.editText(ctx, msg.Chat.ID, pi.MsgID,
"Invalid calendar. Valid options: gregorian, jalali, hijri", &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "caltype"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveCalendar(ctx, msg.Chat.ID, user, text)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID,
fmt.Sprintf("Calendar set to %s", text), &backKB)
}
func (h *Handler) saveCalendar(_ context.Context, _ int64, user *domain.User, cal string) {
user.Calendar = cal
if err := h.Repo.UpdateUser(user); err != nil {
slog.Error("save calendar failed", "user_id", user.ID, "error", err)
}
}
func (h *Handler) processBreakInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) { func (h *Handler) processBreakInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
mins, err := strconv.Atoi(text) mins, err := strconv.Atoi(text)
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins { if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
@@ -683,15 +746,20 @@ func (h *Handler) saveRate(ctx context.Context, chatID int64, user *domain.User,
func (h *Handler) saveCurrency(_ context.Context, _ int64, user *domain.User, currency string) { func (h *Handler) saveCurrency(_ context.Context, _ int64, user *domain.User, currency string) {
st, err := h.Repo.GetOrCreateSettings(user.ID) st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil { if err != nil {
slog.Error("save currency: get settings failed", "user_id", user.ID, "error", err)
return return
} }
st.Currency = currency st.Currency = currency
h.Repo.UpdateSettings(st) if err := h.Repo.UpdateSettings(st); err != nil {
slog.Error("save currency: update settings failed", "user_id", user.ID, "error", err)
}
} }
func (h *Handler) saveTimezone(_ context.Context, _ int64, user *domain.User, tz string) { func (h *Handler) saveTimezone(_ context.Context, _ int64, user *domain.User, tz string) {
user.Timezone = tz user.Timezone = tz
h.Repo.UpdateUser(user) if err := h.Repo.UpdateUser(user); err != nil {
slog.Error("save timezone failed", "user_id", user.ID, "error", err)
}
} }
func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.User, mins int) { func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.User, mins int) {
@@ -699,10 +767,13 @@ func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.Us
today := time.Now().In(loc).Format(domain.DateLayout) today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today) day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil { if err != nil {
slog.Error("save break threshold: get day failed", "user_id", user.ID, "error", err)
return return
} }
day.MinBreakThreshold = int64(mins) * 60 day.MinBreakThreshold = int64(mins) * 60
h.Repo.UpdateDay(day) if err := h.Repo.UpdateDay(day); err != nil {
slog.Error("save break threshold: update day failed", "user_id", user.ID, "error", err)
}
} }
func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, name string) { func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, name string) {
@@ -711,7 +782,9 @@ func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, na
return return
} }
user.Username = name user.Username = name
h.Repo.UpdateUser(user) if err := h.Repo.UpdateUser(user); err != nil {
slog.Error("save username failed", "user_id", user.ID, "error", err)
}
} }
func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *domain.User, timeStr string) error { func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *domain.User, timeStr string) error {
@@ -730,3 +803,26 @@ func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *do
st.ReportTime = timeStr st.ReportTime = timeStr
return h.Repo.UpdateSettings(st) return h.Repo.UpdateSettings(st)
} }
func (h *Handler) deleteAccountPrompt(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", user.ID), Style: "danger"},
{Text: "Cancel", CallbackData: "back_settings"},
},
},
}
h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (events, days, RPG progress, settings, achievements).\nAre you sure?", &kb)
}
func (h *Handler) deleteAccountConfirm(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
if err := h.Repo.DeleteUserData(user.ID); err != nil {
slog.Error("delete user data", "user_id", user.ID, "error", err)
h.editText(ctx, chatID, msgID, "Error deleting data. Please try again.", nil)
return
}
h.editText(ctx, chatID, msgID, "All your data has been deleted. You can start fresh by sending /start.", nil)
}

View File

@@ -44,6 +44,8 @@ type Repository interface {
UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error
SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error
SumDailyWorkSeconds(userID int64) (int64, error) SumDailyWorkSeconds(userID int64) (int64, error)
GetDailyXPInRange(userID int64, start, end string) ([]domain.DailyXP, error)
SumWorkSecondsByWorkType(userID int64) (remoteSeconds, onsiteSeconds int64, _ error)
// Achievements // Achievements
GetAchievementByCode(code string) (*domain.Achievement, error) GetAchievementByCode(code string) (*domain.Achievement, error)
@@ -55,5 +57,6 @@ type Repository interface {
GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error) GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error)
// Lifecycle // Lifecycle
DeleteUserData(userID int64) error
Close() error Close() error
} }

View File

@@ -21,20 +21,55 @@ type Store struct {
func NewStore(path string) (*Store, error) { func NewStore(path string) (*Store, error) {
db, err := sql.Open(driverName, path) db, err := sql.Open(driverName, path)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("open: %w", err)
} }
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
return nil, err return nil, fmt.Errorf("ping: %w", err)
} }
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
slog.Warn("failed to set WAL mode", "error", err) slog.Warn("failed to set WAL mode", "error", err)
} }
if err := runMigrations(db); err != nil { if err := runMigrations(db); err != nil {
return nil, err return nil, fmt.Errorf("migrate: %w", err)
} }
return &Store{db: db}, nil return &Store{db: db}, nil
} }
func (s *Store) DeleteUserData(userID int64) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("delete user data begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM events WHERE day_id IN (SELECT id FROM days WHERE user_id=?)`, userID); err != nil {
return fmt.Errorf("delete user data events: %w", err)
}
if _, err := tx.Exec(`DELETE FROM days WHERE user_id=?`, userID); err != nil {
return fmt.Errorf("delete user data days: %w", err)
}
if _, err := tx.Exec(`DELETE FROM user_achievements WHERE user_id=?`, userID); err != nil {
return fmt.Errorf("delete user data achievements: %w", err)
}
if _, err := tx.Exec(`DELETE FROM daily_xp WHERE user_id=?`, userID); err != nil {
return fmt.Errorf("delete user data daily_xp: %w", err)
}
if _, err := tx.Exec(`DELETE FROM user_rpg WHERE user_id=?`, userID); err != nil {
return fmt.Errorf("delete user data rpg: %w", err)
}
if _, err := tx.Exec(`DELETE FROM user_settings WHERE user_id=?`, userID); err != nil {
return fmt.Errorf("delete user data settings: %w", err)
}
if _, err := tx.Exec(`DELETE FROM users WHERE id=?`, userID); err != nil {
return fmt.Errorf("delete user data users: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("delete user data commit: %w", err)
}
return nil
}
func (s *Store) Close() error { func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
@@ -66,7 +101,7 @@ func (s *Store) GetOrCreateUser(chatID int64) (*domain.User, error) {
"INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)", "INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)",
chatID, randomUsername(), chatID, randomUsername(),
); err != nil { ); err != nil {
return nil, err return nil, fmt.Errorf("get or create user: %w", err)
} }
return s.GetUserByChatID(chatID) return s.GetUserByChatID(chatID)
} }
@@ -81,7 +116,7 @@ func (s *Store) GetUserByChatID(chatID int64) (*domain.User, error) {
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive, &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&u.Calendar, &u.CreatedAt, &u.UpdatedAt, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, fmt.Errorf("get user by chat id: %w", err)
} }
return &u, nil return &u, nil
} }
@@ -91,7 +126,10 @@ func (s *Store) UpdateUser(user *domain.User) error {
"UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?", "UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?",
user.Username, user.Timezone, user.IsActive, user.Calendar, user.ID, user.Username, user.Timezone, user.IsActive, user.Calendar, user.ID,
) )
return err if err != nil {
return fmt.Errorf("update user: %w", err)
}
return nil
} }
func (s *Store) GetAllUsers() ([]domain.User, error) { func (s *Store) GetAllUsers() ([]domain.User, error) {
@@ -233,6 +271,57 @@ func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) {
return total, err return total, err
} }
func (s *Store) GetDailyXPInRange(userID int64, start, end string) ([]domain.DailyXP, error) {
rows, err := s.db.Query(
`SELECT id, user_id, date, xp_earned, work_seconds, created_at
FROM daily_xp WHERE user_id=? AND date >= ? AND date <= ?
ORDER BY date`, userID, start, end,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []domain.DailyXP
for rows.Next() {
var x domain.DailyXP
if err := rows.Scan(&x.ID, &x.UserID, &x.Date, &x.XPEarned, &x.WorkSeconds, &x.CreatedAt); err != nil {
return nil, err
}
result = append(result, x)
}
return result, rows.Err()
}
func (s *Store) SumWorkSecondsByWorkType(userID int64) (remoteSeconds, onsiteSeconds int64, _ error) {
// Each day has a current_work_type_id. Daily XP work_seconds are per day.
// Join with days to get the day's primary work type, then sum by type.
rows, err := s.db.Query(`
SELECT COALESCE(d.current_work_type_id, 1), COALESCE(SUM(dx.work_seconds), 0)
FROM daily_xp dx
JOIN days d ON d.user_id = dx.user_id AND d.date = dx.date
WHERE dx.user_id = ? AND dx.work_seconds > 0
GROUP BY d.current_work_type_id
`, userID)
if err != nil {
return 0, 0, err
}
defer rows.Close()
for rows.Next() {
var wtID int64
var secs int64
if err := rows.Scan(&wtID, &secs); err != nil {
return 0, 0, err
}
switch wtID {
case 2:
remoteSeconds += secs
default:
onsiteSeconds += secs
}
}
return remoteSeconds, onsiteSeconds, rows.Err()
}
// Achievements // Achievements
func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) { func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) {

593
internal/repo/store_test.go Normal file
View File

@@ -0,0 +1,593 @@
package repo
import (
"os"
"testing"
"github.com/pressly/goose/v3"
)
func TestMain(m *testing.M) {
goose.SetLogger(goose.NopLogger())
os.Exit(m.Run())
}
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := NewStore("file::memory:?cache=shared")
if err != nil {
t.Fatalf("NewStore() failed: %v", err)
}
return s
}
func TestGetOrCreateUser(t *testing.T) {
s := newTestStore(t)
defer s.Close()
u, err := s.GetOrCreateUser(12345)
if err != nil {
t.Fatalf("GetOrCreateUser() failed: %v", err)
}
if u.ID == 0 {
t.Fatal("GetOrCreateUser() returned zero ID")
}
if u.ChatID != 12345 {
t.Errorf("ChatID = %d, want 12345", u.ChatID)
}
if u.Timezone == "" {
t.Errorf("Timezone is empty")
}
if u.Calendar != "gregorian" {
t.Errorf("Calendar = %q, want gregorian", u.Calendar)
}
u2, err := s.GetOrCreateUser(12345)
if err != nil {
t.Fatalf("GetOrCreateUser() second call failed: %v", err)
}
if u2.ID != u.ID {
t.Errorf("second call returned different ID: %d vs %d", u2.ID, u.ID)
}
u3, err := s.GetOrCreateUser(67890)
if err != nil {
t.Fatalf("GetOrCreateUser() third call failed: %v", err)
}
if u3.ID == u.ID {
t.Error("different chat_id should produce different user ID")
}
}
func TestGetOrCreateSettings(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
st, err := s.GetOrCreateSettings(user.ID)
if err != nil {
t.Fatalf("GetOrCreateSettings() failed: %v", err)
}
if !st.ReportEnabled {
t.Error("ReportEnabled should default to true")
}
if st.ExportAccent != "ocean" {
t.Errorf("ExportAccent = %q, want ocean", st.ExportAccent)
}
st.ExportAccent = "beach"
st.ReportEnabled = false
if err := s.UpdateSettings(st); err != nil {
t.Fatalf("UpdateSettings() failed: %v", err)
}
st2, err := s.GetOrCreateSettings(user.ID)
if err != nil {
t.Fatalf("GetOrCreateSettings() after update failed: %v", err)
}
if st2.ExportAccent != "beach" {
t.Errorf("ExportAccent = %q, want beach", st2.ExportAccent)
}
if st2.ReportEnabled {
t.Error("ReportEnabled should be false after update")
}
}
func TestGetOrCreateDay(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
day, err := s.GetOrCreateDay(user.ID, "2026-06-25")
if err != nil {
t.Fatalf("GetOrCreateDay() failed: %v", err)
}
if day.UserID != user.ID {
t.Errorf("UserID = %d, want %d", day.UserID, user.ID)
}
if day.Date != "2026-06-25" {
t.Errorf("Date = %q, want 2026-06-25", day.Date)
}
if day.IsDayOff {
t.Error("IsDayOff should default to false")
}
if day.MinBreakThreshold != 300 {
t.Errorf("MinBreakThreshold = %d, want 300", day.MinBreakThreshold)
}
day2, err := s.GetOrCreateDay(user.ID, "2026-06-25")
if err != nil {
t.Fatalf("GetOrCreateDay() second call failed: %v", err)
}
if day2.ID != day.ID {
t.Errorf("second call returned different day ID")
}
got, err := s.GetDay(user.ID, "2026-06-25")
if err != nil {
t.Fatalf("GetDay() failed: %v", err)
}
if got.ID != day.ID {
t.Errorf("GetDay() returned different day ID")
}
_, err = s.GetDay(user.ID, "2026-06-26")
if err == nil {
t.Error("GetDay() should fail for non-existent day")
}
}
func TestDayOff(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
if err := s.SetDayOff(user.ID, "2026-06-25", "sick"); err != nil {
t.Fatalf("SetDayOff() failed: %v", err)
}
day, err := s.GetDay(user.ID, "2026-06-25")
if err != nil {
t.Fatalf("GetDay() after SetDayOff failed: %v", err)
}
if !day.IsDayOff {
t.Error("IsDayOff should be true after SetDayOff")
}
if day.DayOffReason != "sick" {
t.Errorf("DayOffReason = %q, want sick", day.DayOffReason)
}
if err := s.RemoveDayOff(user.ID, "2026-06-25"); err != nil {
t.Fatalf("RemoveDayOff() failed: %v", err)
}
day, _ = s.GetDay(user.ID, "2026-06-25")
if day.IsDayOff {
t.Error("IsDayOff should be false after RemoveDayOff")
}
}
func TestCreateAndQueryEvents(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
day, _ := s.GetOrCreateDay(user.ID, "2026-06-25")
wt := int64(2)
if err := s.CreateEvent(user.ID, day.ID, "in", &wt, 1000, "note1"); err != nil {
t.Fatalf("CreateEvent() in failed: %v", err)
}
if err := s.CreateEvent(user.ID, day.ID, "out", nil, 2000, ""); err != nil {
t.Fatalf("CreateEvent() out failed: %v", err)
}
events, err := s.EventsForDayByDate(user.ID, "2026-06-25")
if err != nil {
t.Fatalf("EventsForDayByDate() failed: %v", err)
}
if len(events) != 2 {
t.Fatalf("got %d events, want 2", len(events))
}
if events[0].EventType != "in" || events[0].OccurredAt != 1000 {
t.Errorf("first event: type=%q, time=%d", events[0].EventType, events[0].OccurredAt)
}
if events[0].WorkTypeID == nil || *events[0].WorkTypeID != 2 {
t.Error("first event WorkTypeID should be 2")
}
if events[0].Note != "note1" {
t.Errorf("first event Note = %q, want note1", events[0].Note)
}
if events[1].EventType != "out" || events[1].OccurredAt != 2000 {
t.Errorf("second event: type=%q, time=%d", events[1].EventType, events[1].OccurredAt)
}
if events[1].WorkTypeID != nil {
t.Error("out event WorkTypeID should be nil")
}
last, err := s.GetLastEvent(user.ID)
if err != nil {
t.Fatalf("GetLastEvent() failed: %v", err)
}
if last.EventType != "out" || last.OccurredAt != 2000 {
t.Errorf("last event: type=%q, time=%d", last.EventType, last.OccurredAt)
}
}
func TestCRUDEvent(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
day, _ := s.GetOrCreateDay(user.ID, "2026-06-25")
wt := int64(1)
s.CreateEvent(user.ID, day.ID, "in", &wt, 100, "")
events, _ := s.EventsForDayByDate(user.ID, "2026-06-25")
eid := events[0].ID
if err := s.UpdateEventTime(eid, 150); err != nil {
t.Fatalf("UpdateEventTime() failed: %v", err)
}
if err := s.UpdateEventWorkType(eid, 2); err != nil {
t.Fatalf("UpdateEventWorkType() failed: %v", err)
}
if err := s.SetEventNote(eid, "updated"); err != nil {
t.Fatalf("SetEventNote() failed: %v", err)
}
e, err := s.GetEventByID(eid, user.ID)
if err != nil {
t.Fatalf("GetEventByID() failed: %v", err)
}
if e.OccurredAt != 150 {
t.Errorf("OccurredAt = %d, want 150", e.OccurredAt)
}
if e.WorkTypeID == nil || *e.WorkTypeID != 2 {
t.Error("WorkTypeID should be 2")
}
if e.Note != "updated" {
t.Errorf("Note = %q, want updated", e.Note)
}
if err := s.DeleteEvent(eid); err != nil {
t.Fatalf("DeleteEvent() failed: %v", err)
}
events, _ = s.EventsForDayByDate(user.ID, "2026-06-25")
if len(events) != 0 {
t.Errorf("after delete: got %d events, want 0", len(events))
}
}
func TestRPGStats(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
stats, err := s.GetOrCreateRPGStats(user.ID)
if err != nil {
t.Fatalf("GetOrCreateRPGStats() failed: %v", err)
}
if stats.Level != 1 {
t.Errorf("Level = %d, want 1", stats.Level)
}
if stats.XP != 0 {
t.Errorf("XP = %d, want 0", stats.XP)
}
stats.XP = 5000
stats.Level = 10
stats.CurrentStreak = 5
stats.LongestStreak = 10
stats.TotalWorkSeconds = 36000
if err := s.UpdateRPGStats(stats); err != nil {
t.Fatalf("UpdateRPGStats() failed: %v", err)
}
stats2, _ := s.GetOrCreateRPGStats(user.ID)
if stats2.XP != 5000 || stats2.Level != 10 {
t.Errorf("after update: XP=%d Level=%d, want 5000 10", stats2.XP, stats2.Level)
}
if stats2.CurrentStreak != 5 || stats2.LongestStreak != 10 {
t.Errorf("after update: CurrentStreak=%d LongestStreak=%d, want 5 10", stats2.CurrentStreak, stats2.LongestStreak)
}
}
func TestDailyXP(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
if err := s.UpsertDailyXP(user.ID, "2026-06-25", 100, 3600); err != nil {
t.Fatalf("UpsertDailyXP() failed: %v", err)
}
if err := s.UpsertDailyXP(user.ID, "2026-06-26", 200, 7200); err != nil {
t.Fatalf("UpsertDailyXP() second call failed: %v", err)
}
if err := s.UpsertDailyXP(user.ID, "2026-06-25", 50, 1800); err != nil {
t.Fatalf("UpsertDailyXP() third call failed: %v", err)
}
total, err := s.SumDailyWorkSeconds(user.ID)
if err != nil {
t.Fatalf("SumDailyWorkSeconds() failed: %v", err)
}
if total != 12600 {
t.Errorf("SumDailyWorkSeconds = %d, want 12600 (3600+7200+1800)", total)
}
records, err := s.GetDailyXPInRange(user.ID, "2026-06-25", "2026-06-26")
if err != nil {
t.Fatalf("GetDailyXPInRange() failed: %v", err)
}
if len(records) != 2 {
t.Fatalf("got %d records, want 2", len(records))
}
if records[0].Date != "2026-06-25" || records[0].WorkSeconds != 5400 {
t.Errorf("record[0]: date=%q work=%d, want 2026-06-25 5400", records[0].Date, records[0].WorkSeconds)
}
if records[1].Date != "2026-06-26" || records[1].WorkSeconds != 7200 {
t.Errorf("record[1]: date=%q work=%d, want 2026-06-26 7200", records[1].Date, records[1].WorkSeconds)
}
s.SetDailyWorkSeconds(user.ID, "2026-06-25", 3000)
records, _ = s.GetDailyXPInRange(user.ID, "2026-06-25", "2026-06-25")
if len(records) != 1 || records[0].WorkSeconds != 3000 {
t.Errorf("after SetDailyWorkSeconds: work=%d, want 3000", records[0].WorkSeconds)
}
}
func TestAchievements(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
a, err := s.GetAchievementByCode("first_clockin")
if err != nil {
t.Fatalf("GetAchievementByCode() failed: %v", err)
}
if a.Code != "first_clockin" || a.Name == "" {
t.Errorf("achievement: code=%q name=%q", a.Code, a.Name)
}
has, err := s.HasAchievement(user.ID, "first_clockin")
if err != nil {
t.Fatalf("HasAchievement() failed: %v", err)
}
if has {
t.Error("HasAchievement should be false before unlocking")
}
ok, err := s.UnlockAchievement(user.ID, a.ID)
if err != nil {
t.Fatalf("UnlockAchievement() failed: %v", err)
}
if !ok {
t.Error("UnlockAchievement should return true on first unlock")
}
ok, err = s.UnlockAchievement(user.ID, a.ID)
if err != nil {
t.Fatalf("UnlockAchievement() duplicate failed: %v", err)
}
if ok {
t.Error("UnlockAchievement should return false on duplicate")
}
has, _ = s.HasAchievement(user.ID, "first_clockin")
if !has {
t.Error("HasAchievement should be true after unlocking")
}
achs, err := s.GetUserAchievements(user.ID)
if err != nil {
t.Fatalf("GetUserAchievements() failed: %v", err)
}
if len(achs) != 1 {
t.Fatalf("got %d achievements, want 1", len(achs))
}
if achs[0].Code != "first_clockin" {
t.Errorf("achievement code = %q, want first_clockin", achs[0].Code)
}
}
func TestGetWorkTypes(t *testing.T) {
s := newTestStore(t)
defer s.Close()
wts, err := s.GetWorkTypes()
if err != nil {
t.Fatalf("GetWorkTypes() failed: %v", err)
}
if len(wts) != 2 {
t.Fatalf("got %d work types, want 2", len(wts))
}
wt, err := s.GetWorkType(1)
if err != nil {
t.Fatalf("GetWorkType(1) failed: %v", err)
}
if wt.Name != "onsite" {
t.Errorf("WorkType 1 = %q, want onsite", wt.Name)
}
_, err = s.GetWorkType(999)
if err == nil {
t.Error("GetWorkType(999) should fail")
}
}
func TestGetUserDaysInRange(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
s.GetOrCreateDay(user.ID, "2026-06-01")
s.GetOrCreateDay(user.ID, "2026-06-02")
s.GetOrCreateDay(user.ID, "2026-06-05")
days, err := s.GetUserDaysInRange(user.ID, "2026-06-01", "2026-06-10")
if err != nil {
t.Fatalf("GetUserDaysInRange() failed: %v", err)
}
if len(days) != 3 {
t.Fatalf("got %d days, want 3", len(days))
}
days, _ = s.GetUserDaysInRange(user.ID, "2026-06-03", "2026-06-10")
if len(days) != 1 {
t.Fatalf("got %d days in narrow range, want 1", len(days))
}
}
func TestGetDatesWithEvents(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
d1, _ := s.GetOrCreateDay(user.ID, "2026-06-01")
d2, _ := s.GetOrCreateDay(user.ID, "2026-06-02")
s.GetOrCreateDay(user.ID, "2026-06-03")
s.CreateEvent(user.ID, d1.ID, "in", nil, 100, "")
s.CreateEvent(user.ID, d2.ID, "in", nil, 200, "")
dates, err := s.GetDatesWithEvents(user.ID)
if err != nil {
t.Fatalf("GetDatesWithEvents() failed: %v", err)
}
if len(dates) != 2 {
t.Fatalf("got %d dates, want 2", len(dates))
}
distinct, err := s.GetDistinctEventDates(user.ID, "2026-06-01", "2026-06-30")
if err != nil {
t.Fatalf("GetDistinctEventDates() failed: %v", err)
}
if len(distinct) != 2 {
t.Fatalf("got %d distinct dates, want 2", len(distinct))
}
}
func TestSumWorkSecondsByWorkType(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
day, _ := s.GetOrCreateDay(user.ID, "2026-06-25")
wt2 := int64(2)
s.CreateEvent(user.ID, day.ID, "in", &wt2, 100, "")
s.CreateEvent(user.ID, day.ID, "out", nil, 3600, "")
s.SetDailyWorkSeconds(user.ID, "2026-06-25", 3500)
rem, ons, err := s.SumWorkSecondsByWorkType(user.ID)
if err != nil {
t.Fatalf("SumWorkSecondsByWorkType() failed: %v", err)
}
if rem+ons == 0 {
t.Error("expected non-zero work seconds, got 0 for both types")
}
}
func TestDeleteUserData(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
s.GetOrCreateSettings(user.ID)
s.GetOrCreateRPGStats(user.ID)
s.GetOrCreateDay(user.ID, "2026-06-25")
s.UpsertDailyXP(user.ID, "2026-06-25", 100, 3600)
a, _ := s.GetAchievementByCode("first_clockin")
s.UnlockAchievement(user.ID, a.ID)
if err := s.DeleteUserData(user.ID); err != nil {
t.Fatalf("DeleteUserData() failed: %v", err)
}
user2, err := s.GetOrCreateUser(12345)
if err != nil {
t.Fatalf("GetOrCreateUser() after delete failed: %v", err)
}
if user2.ID <= user.ID {
t.Error("new user should get a fresh ID")
}
total, _ := s.SumDailyWorkSeconds(user.ID)
if total != 0 {
t.Errorf("work seconds after delete = %d, want 0", total)
}
}
func TestMarkReportSent(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
if err := s.MarkReportSent(user.ID, "2026-06-25"); err != nil {
t.Fatalf("MarkReportSent() failed: %v", err)
}
settings, _ := s.GetOrCreateSettings(user.ID)
if settings.LastReportDate != "2026-06-25" {
t.Errorf("LastReportDate = %q, want 2026-06-25", settings.LastReportDate)
}
}
func TestGetAllUsers(t *testing.T) {
s := newTestStore(t)
defer s.Close()
s.GetOrCreateUser(12345)
s.GetOrCreateUser(67890)
users, err := s.GetAllUsers()
if err != nil {
t.Fatalf("GetAllUsers() failed: %v", err)
}
if len(users) != 2 {
t.Fatalf("got %d users, want 2", len(users))
}
}
func TestUpdateUserAndDay(t *testing.T) {
s := newTestStore(t)
defer s.Close()
user, _ := s.GetOrCreateUser(12345)
user.Timezone = "America/New_York"
user.Username = "testuser"
if err := s.UpdateUser(user); err != nil {
t.Fatalf("UpdateUser() failed: %v", err)
}
user2, _ := s.GetOrCreateUser(12345)
if user2.Timezone != "America/New_York" {
t.Errorf("Timezone = %q, want America/New_York", user2.Timezone)
}
if user2.Username != "testuser" {
t.Errorf("Username = %q, want testuser", user2.Username)
}
day, _ := s.GetOrCreateDay(user.ID, "2026-06-25")
day.MinBreakThreshold = 600
if err := s.UpdateDay(day); err != nil {
t.Fatalf("UpdateDay() failed: %v", err)
}
day2, _ := s.GetDay(user.ID, "2026-06-25")
if day2.MinBreakThreshold != 600 {
t.Errorf("MinBreakThreshold = %d, want 600", day2.MinBreakThreshold)
}
}

View File

@@ -1,146 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"llama.cpp": {
"npm": "@ai-sdk/openai-compatible",
"name": "llama-server (local)",
"options": {
"baseURL": "http://127.0.0.1:8082/v1"
},
"models": {
"coder": {
"name": "coder",
"limit": {
"context": 32000,
"output": 65536
}
}
}
}
},
"lsp": true,
"formatter": {
"gofmt": {
"command": ["gofmt", "-w", "$FILE"],
"extensions": [".go"]
},
"prettier": {
"command": ["prettier", "--write", "$FILE"],
"extensions": [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".vue",
".html",
".htm",
".css",
".scss",
".less",
".sass",
".json",
".jsonc",
".yaml",
".yml",
".md",
".mdx"
]
},
"php-cs-fixer": {
"command": ["php-cs-fixer", "fix", "$FILE"],
"extensions": [".php"]
}
},
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp@latest"],
"enabled": false
},
"postgres": {
"type": "local",
"command": ["uvx", "postgres-mcp"],
"enabled": false,
"environment": {
"DATABASE_URI": "postgresql://user:pass@localhost:5432/mydb"
}
},
"mariadb": {
"type": "local",
"command": ["npx", "-y", "@oleander/mcp-server-mariadb"],
"enabled": false,
"environment": {
"MARIADB_HOST": "127.0.0.1",
"MARIADB_PORT": "3306",
"MARIADB_USER": "root",
"MARIADB_PASS": "",
"MARIADB_DB": "mydb"
}
},
"redis": {
"type": "local",
"command": ["uvx", "redis-mcp-server"],
"enabled": false,
"environment": {
"REDIS_URL": "redis://localhost:6379"
}
},
"sqlite": {
"type": "local",
"command": ["npx", "-y", "mcp-sqlite", "./data/dev.db"],
"enabled": false
},
"docker": {
"type": "local",
"command": ["uvx", "mcp-server-docker"],
"enabled": false
},
"gitea": {
"type": "local",
"command": ["uvx", "gitea-mcp"],
"enabled": false,
"environment": {
"GITEA_URL": "https://git.db1232.ir",
"GITEA_TOKEN": "your-personal-access-token"
}
},
"matrix": {
"type": "local",
"command": ["npx", "-y", "@iflow-mcp/mjknowles-matrix-mcp-server"],
"enabled": false,
"environment": {
"MATRIX_HOMESERVER_URL": "https://tempchat.db123.ir",
"MATRIX_ACCESS_TOKEN": "your-access-token"
}
},
"fallow": {
"type": "local",
"command": ["npx", "-y", "--package", "fallow", "fallow-mcp"],
"enabled": false
}
},
"permission": {
"read": "allow",
"glob": "allow",
"grep": "allow",
"list": "allow",
"lsp": "allow",
"webfetch": "allow",
"websearch": "allow",
"question": "allow",
"todowrite": "allow",
"edit": "ask",
"write": "ask",
"task": "ask",
"external_directory": "ask",
"skill": "ask",
"doom_loop": "deny",
"bash": {
"*": "ask",
"git push*": "deny",
"rm -rf*": "deny"
}
}
}