Compare commits

...

77 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
0926323d18 fix: removed an stray space character in text of main menu league
All checks were successful
CI / build (push) Successful in 1m6s
2026-06-25 09:58:54 +03:30
a96419f324 feat: add /settings command and document /history for UI-command parity
- Add /settings command handler with inline settings keyboard
- Add /history to README commands table
- Update /help command list
- Remove unused Leagu.go old reference
2026-06-25 09:55:53 +03:30
d15ed46066 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
2026-06-25 09:54:22 +03:30
3835b002a7 fix: corrected the layout of buttons in settings and text in main menu 2026-06-25 09:30:41 +03:30
3cd692be0f fix: separate RPG and League into their own rows in settings menu 2026-06-25 09:19:32 +03:30
b822068f0a feat: enforce RPG dependency for League — cannot enable League without RPG
- handleSelectCallback: disabling RPG also disables League; enabling
  League when RPG is off shows toast error via answerCbWithText
- handleLeagueToggleCmd (/leaguetoggle): rejects with message if RPG off
- leagueToggleCallback (inline): rejects with message if RPG off
- buildSettingsKeyboard: League status shows as 'off' when RPG is disabled
  (even if the DB still has LeagueOptIn=true, since RPG disable forces it false)
2026-06-25 09:19:03 +03:30
80de661f5b refactor: eliminate store bypass and fix GetLastEvent style
- Add GetDistinctEventDates, UpdateEventWorkType, UpdateEventTime,
  DeleteEvent, GetEventByID store methods to calendar.go
- Replace all 5 raw h.DB.DB().Exec/Query calls with proper store methods
- Remove unused database/sql import from calendar.go
- Rewrite GetLastEvent to use QueryRow instead of Query+manual iteration
  (fixing row-vs-rows naming issue; removing rows.Close boilerplate)
- Add errors import to store.go for errors.Is
2026-06-25 02:29:30 +03:30
af23f80fbb fix: address audit findings — nil pointers, timezone bug, dead code, tests, README
- Fix nil pointer dereference in clock.go (guard GetOrCreateSettings and
  GetOrCreateRPGStats errors before accessing fields)
- Fix timezone bug in handleSetBreakMsg (was using system time.Now()
  instead of user's configured timezone)
- Remove dead code: sendExportMonthPicker (unused wrapper)
- Simplify xpForWorkSeconds (xpPerSecond=1, inline to just return sec)
- Extract computeStreakFromDates as pure testable function from
  recalcAggregates; add 6 tests covering empty, current, gaps,
  no-today, long streak, longest-not-current edge cases
- Update README with full feature documentation: RPG, League, Salary,
  Burnout, Work Types, Inline History Editing, all missing commands
2026-06-25 02:25:13 +03:30
5cd811e057 refactor: restore stored aggregates with incremental update on edit
- Restore TotalWorkSeconds, CurrentStreak, LongestStreak writes in
  computeAndAwardXP and updateStreak (incremental on clock out)
- Add recalcDayWorkSeconds and recalcAggregates for event edit path
  (recompute day + total + streak from scratch when events change)
- Call recalcAggregates after editEventTimeSet, deleteEventConfirm,
  addEventDo in calendar.go
- Remove runtime computeStreaks and totalWorkSeconds (N+1 scan)
  from RPG display, burnout, and clockOut
- Add SetDailyWorkSeconds, SumDailyWorkSeconds store methods
- Clean up unused dead functions and dead code in rpg.go
2026-06-25 02:19:40 +03:30
4fcb694418 fix: compute work hours from events at runtime; add Achievements button to RPG view 2026-06-25 02:08:33 +03:30
85587d563d fix: computeTrend division by zero, add tests for trend and currency parsing, add username to settings 2026-06-25 01:58:27 +03:30
88ccc6aa68 feat: show Anonymous in league when name empty, add username to settings, show salary hint in summary 2026-06-25 01:54:37 +03:30
605c41ab88 fix: currency validation, league mobile layout, settings 2-col, burnout ratio, add setusername
- Require both symbol+code in /setcurrency; add preset picker ($ USD,
  T Toman, IRR Rial, EUR, GBP) in settings inline menu
- League: compact 2-line per entry for mobile; sort buttons in 2x2 grid
- Settings: 2-column layout with grouped buttons (timezone+calendar,
  report+report time, break+accent, rpg+league, currency+rate)
- Burnout: cap break ratio at 100% to prevent nonsense values
- Add /setusername command (uses existing SanitizeDisplayName)
- Update /help with new/changed commands
2026-06-25 01:46:07 +03:30
344c615666 refactor: break up large functions, add RPG/league/salary/burnout, fix bugs, add tests
- Refactor GenerateMonthlyReport (198→70), HandleCallback (128→85),
  handleHistoryCallback (131→38), handleExportCallback (100→25),
  checkAchievements (86→25) into extracted helper functions
- Add RPG system (XP, levels, streak, achievements, burnout)
- Add WorkTime League leaderboard
- Add salary estimation with configurable currency/rate
- Add input sanitization (SanitizeNote, SanitizeDisplayName)
- Add settings select-style pickers for toggles (report, RPG, league)
- Add break threshold inline picker UI
- Add event notes with pending state and /note command
- Add multi-calendar support (Jalali, Hijri)
- Add Excel export with theme picker
- Fix: getTodayBreakThreshold uses user's timezone (was UTC)
- Fix: acknowledgeCallback passes real callback ID
- Fix: currency symbol safety with currencySymbol() helper
- Fix: count work hours in summary on day-off days
- Fix: unsilence all error returns from SQL Exec/Bot API/migrations
- Remove stale db/schema.sql and unreferenced computeWeekTotals
- Extract constants: DateLayout, TimeLayout, secondsPerHour, etc.
- Add 12 new tests (XP, salary, sanitize, currency, dateutil)
- Remove duplicate package doc comment in totals.go
2026-06-25 01:02:16 +03:30
0d942c51db refactor: simplify break threshold picker values and labels 2026-06-24 21:06:23 +03:30
5f12e3838b feat: add break threshold picker UI to settings with preset values
Replaces the text-only instructions with a grid of common break thresholds (0/5/10/15/30 min, 1/2/4/8h) matching the accent picker pattern.
2026-06-24 20:59:49 +03:30
e399c3ee88 feat: add break threshold to settings UI with inline button and callback 2026-06-24 20:58:18 +03:30
c9dd076235 fix: count work hours in summary even on day-off days
buildMonthlySummary now includes hours for days marked as day-off, matching buildStatusText behavior. Shows "Xh worked (Day Off)" when a day-off day has events.
2026-06-24 20:53:15 +03:30
e539350030 refactor: simplify calendar nav buttons by removing redundant year/step numbers 2026-06-24 20:49:07 +03:30
834c318271 feat: add /summary, /setbreak, weekly totals, and fix command dispatch for arg-bearing commands
- Add /summary [YYYY-MM] command for monthly work/break/day-off totals

- Add weekly totals row to main menu status

- Add /setbreak <minutes> for configurable daily break threshold

- Fix HandleMessage routing: extract command name instead of exact match (fixes /export YYYY-MM, /edit, /note, /summary, /setbreak)

- Update README with full command table and configurable break docs
2026-06-24 20:39:51 +03:30
323c1bc010 test: add dateutil and totals tests for calendar conversions and ComputeDailyTotals 2026-06-24 20:07:38 +03:30
e7e4dc7bfe fix: unsilence all error returns from SQL Exec, Bot API calls, and migrations 2026-06-24 20:03:24 +03:30
4165839477 feat: add event notes with pending state, 5-min TTL, cancel, and /note command 2026-06-24 19:49:32 +03:30
68c8518522 add year navigation: year row at top of calendar, clickable year in export, shared buildYearPicker with 12-year grid 2026-06-24 19:47:03 +03:30
8d919decb0 export: tint day-off rows with theme totalFill background 2026-06-24 19:23:16 +03:30
4edff71bfd replace manual SQL migration with goose (v3); embed migrations in binary; remove schema.sql / SCHEMA_PATH 2026-06-24 19:20:48 +03:30
c959cb7a87 export: include all days, show Day Off in type column; menu: don't hide status on day off 2026-06-24 19:06:54 +03:30
af7a438174 feat: extended the build minute keyboard to have each 10 minutes from 0 to 50 2026-06-24 15:06:08 +03:30
353056349f DRY hour/minute picker: extract shared buildHourPickerKeyboard/buildMinutePickerKeyboard 2026-06-24 15:04:22 +03:30
75da4648aa feat: add IN/OUT event creation from day view in history
- Empty day view now shows Add IN / Add OUT buttons
- Day view with events also shows Add IN / Add OUT below existing events
- Hour picker → minute picker (15-min intervals) → event creation flow
- Back button returns to the day view
2026-06-24 15:02:08 +03:30
49c0e82bac refactor: migrate from go-telegram-bot-api/v5 to go-telegram/bot
- Replaced tgbotapi with go-telegram/bot (zero-dependency, context-aware, Bot API 10.0)
- All handler methods now accept context.Context; threading ctx through all sends/edits
- Changed from function-based (NewMessage/NewEditMessageText/NewInlineKeyboardMarkup) to struct-param API (SendMessageParams/EditMessageTextParams/InlineKeyboardMarkup)
- Added colored buttons: DEL buttons in calendar use Style: 'danger' (red)
- Both polling and webhook modes preserved with new library patterns
- Context-based shutdown (signal.NotifyContext) replaces stop channel
2026-06-24 14:56:50 +03:30
fb2d0ef7d1 chore: changed the rate limit down to 1 second instead of 2 2026-06-24 11:39:47 +03:30
a8b849f8fd refactor: split handlers.go into 5 files, redesign export month picker, pad calendar rows, improve comments
- Split 1571-line handlers.go into: handlers.go (core), clock.go, settings.go, calendar.go, report.go
- Redesigned export month picker: year navigation + 12-month grid instead of prev/next month
- Fixed calendar last row: pad remaining cells with empty buttons to ensure 7 columns
- Added Go doc comments across all files (dateutil.go, totals.go, store.go, main.go, webhook.go)
2026-06-24 11:33:17 +03:30
d8599657f4 refactor: export full Gregorian range, add timezone fallback, cleanup rate limit, configurable schema path
- GenerateMonthlyReport now takes explicit Gregorian start/end range,
  computed via monthGregorianRange, so Jalali/Hijri months spanning
  two Gregorian months are fully covered in exports.
- Added loadLocation helper (UTC fallback on error) and replaced all
  11 ignored-error call sites in handlers + export.
- Added periodicCleanup goroutine in NewHandler to prevent unbounded
  growth of the rateLimit map.
- schemaPath is now read from SCHEMA_PATH env var (init), defaulting
  to /app/db/schema.sql.
- Migrated sendDayView to use sendOrEdit helper, removing duplicated
  send/edit branching.
- Removed dead code: userYearMonthToGregorian (unused), the old
  startOffset function (replaced).
- Updated README with calendar docs, new env vars, full project tree.
- Updated .env.example and docker-compose.yml with SCHEMA_PATH.
- Simplified Makefile: added fmt, tidy, check, run-dev targets;
  removed broken Make-glob prerequisite pattern.
2026-06-24 11:21:54 +03:30
f89f5607aa fix: calendar day-of-week offset off by one, nav double-jump, race conditions
- Fix Jalali/Gregorian/Hijri day-of-week startOffset formulas
  (dow returns 0=Sat, not 0=Sun as the old comment claimed)
- Fix calendar and export month picker navigation jumping 2 months
  (navMonth buttons already have pre-computed values; handlers
  were decrementing/incrementing them again)
- Fix GetOrCreateUser/GetOrCreateDay race condition with INSERT OR IGNORE
- Add month/day bounds validation in handleEditMsg to prevent panic
2026-06-24 11:07:53 +03:30
577aee91fb refactor: remove dead code, DRY calendar navigation and helpers
- Fix Jalali calendar weekday offset in buildCalendarMonth
- Remove unused gregorianDateKey function and PairCount field
- Replace inline export month title with formatMonthTitle
- Extract navMonth helper for prev/next month navigation
- Extract sendOrEdit helper for send-vs-edit message pattern
- Extract monthGregorianRange helper to simplify editCalendar 3-way switch
- Replace export displayDate conversion with formatDateForCalendar
2026-06-24 10:54:06 +03:30
67eae5ffa3 fix export timezone and calendar month title
- Export now uses time.Now().In(loc) for year/month so users in
  positive UTC offsets get the correct month
- Export month title for Jalali/Hijri now correctly converts the
  first day of the Gregorian month to the target calendar instead
  of using the Gregorian month index to look up target month names
2026-06-24 10:32:17 +03:30
8fe43bff4d add settimezone command and timezone display in settings
- New /settimezone <IANA name> command to change user timezone
- Shows current timezone in settings menu
- timezone callback shows current value and instructions
- Updated /help with the new command
2026-06-24 10:17:09 +03:30
069bbb614e convert calendar day view header to user's calendar type 2026-06-24 10:14:09 +03:30
cafa139198 add /help command 2026-06-24 10:11:18 +03:30
69c5e20d8e display date in user's selected calendar on main menu and reports
buildStatusText and buildDailyReport now use formatDateForCalendar
to convert the stored Gregorian date to the user's calendar type
(jalali/hijri/gregorian).
2026-06-24 10:06:39 +03:30
07306cb0f0 remove reply keyboard; keep Clock In/Out as inline buttons only
- Removed smartKeyboard, defaultKeyboard, updateReplyKeyboard
- Removed statusMsg/keyboardMsg tracking from Handler
- sendText now sends plain messages (no reply keyboard)
- handleActionMsg sends result with mainKeyboard() inline keyboard
- handleActionCallback edits message with backKeyboard (unchanged)
- handleStart sends welcome + menu with mainKeyboard
- SendDailyReport sends plain text report
- Removed text-trigger 'Clock In'/'Clock Out' message handlers
  (only /clockin and /clockout commands remain)
2026-06-24 10:00:27 +03:30
9c83ed98c6 fix: keep keyboard update message alive instead of send-delete pattern
Previous approach (send \u200C then immediately delete it) lost the
reply keyboard because Telegram may not persist the keyboard from a
deleted message. Now we delete the OLD keyboard message and keep the
new one, ensuring the reply keyboard is always present and correct.
2026-06-24 09:52:28 +03:30
b6c7cf81c8 fix: prefix calendar type callbacks with caltype_ to avoid conflict with cal_day/cal_prev/cal_next
Routes caltype_gregorian/jalali/hijri to selectCalendar and
cal_day/cal_prev/cal_next to handleHistoryCallback as intended.
2026-06-24 09:50:28 +03:30
b900710196 fix: correct Jalali calendar year conversion
Complete rewrite of gregorianToJalali and jalaliToGregorian using
proper JDN arithmetic. Removed broken epoch offset (226899 / year 979)
and replaced with jalaliEpochJDN = 1948320 (March 21, 622 CE).

Verified: 2026-06-24 -> Jalali 1405-04-03 (correct)
Nowruz 1405 verified as 2026-03-21
2026-06-24 09:48:39 +03:30
9d123316e6 feat: Hijri calendar, virtual midnight crossover, overlap rejection, WAL mode
- Hijri (قمری) calendar added as 3rd calendar option with tabular conversion
- Calendar type select menu (like accent) with gregorian/jalali/hijri
- Calendar type applied to exports and reports
- Midnight crossover: virtual splits at computation time (no synthetic DB events)
  - Handles both UTC and local timezone boundaries
  - Timezone changes handled naturally (recomputed on-the-fly)
- Rate limiting per user ID instead of chat ID
- WAL mode enabled for SQLite
- Smart reply keyboard updates after every state change
- Overlapping time edits rejected (must preserve in/out alternation)
- Single event deletion warns if timeline needs repair
- Day off no longer prevents clock in/out
- Accent colors shown with descriptive names in select menu
- Delete button label changed to DEL for visual distinction
2026-06-24 09:39:15 +03:30
6f6cc6f184 feat: midnight crossover, rate limit, smart keyboard, status menu, settings
- Midnight crossover: auto-close previous day session on clock-in
- Rate limiting: 2s cooldown per chat_id in both messages and callbacks
- Smart reply keyboard: single button changes to Clock In/Out based on state
- Status info on main menu shows: date, work type, working status, today's total
- Settings sub-menu with inline buttons for accent, report toggle, report time
- Accent selection inline keyboard with > indicator and back button
- Notes displayed in daily report (e.g. 'auto midnight')
- Back buttons on all sub-menus (work type, accent, settings)
- Settings button added to main menu
2026-06-24 07:56:04 +03:30
2970b7d3fc feat: redesigned DB schema with users, work_types, days, events 2026-06-24 00:36:16 +03:30
8616ed0867 feat: multi-user isolation and graceful shutdown
- Add chat_id to events, settings, and days_off for per-user data
- Add proper graceful shutdown (signal handling, WaitGroup)
- Separate polling and webhook modes with goroutine management
- Add schema migration from single-user to multi-user schema
- Refactor handlers with shared actionFunc pattern (msg + callback)
- Fix schema path for Docker deployment (/app/db/schema.sql)
- Remove emoji from output text for cleaner formatting
2026-06-24 00:14:41 +03:30
7aada8d118 Add README.md
Some checks failed
CI / build (push) Has been cancelled
2026-06-23 22:36:41 +03:30
b4fab29d45 Clean up: remove unused packages, switch to slog, tidy Makefile 2026-06-23 22:34:01 +03:30
32446a99d1 Fix break tracking (out→in gap), add startup log, clean up /start message 2026-06-23 22:00:25 +03:30
7da6f4cb49 Add BOT_ALLOWED_USERS, fix SetColWidth params, drop fmt import, save chat_id on start 2026-06-23 21:56:18 +03:30
a92c72f46a feat: add persistent reply keyboard with Clock In / Clock Out buttons
Also handle button text (English + Persian) in message router.
2026-06-23 21:44:41 +03:30
5009bb130c chore: remove AGENTS.md (all tasks implemented) 2026-06-23 21:42:55 +03:30
4a5778afe5 feat: add inline keyboard, /start command, and callback handlers
- Show main menu with Clock In/Out, Report, Export, Remote, Day Off buttons
- Each action result includes a 'Back to Menu' button
- Handle callback queries alongside text commands
- Delete stale webhook before switching to polling
- Log i18n initialization error instead of silently ignoring it
2026-06-23 21:42:52 +03:30
0be03f7078 chore: connect bot container to proxy_net for nginx reverse-proxy
This allows nginx-edge to reach the bot via service name
on the shared proxy_net external network.
2026-06-23 21:42:45 +03:30
c4e8c8dc3a fix: make schema idempotent with IF NOT EXISTS
Add i18n locale files to Docker image and fix all CREATE
statements to use IF NOT EXISTS to survive container restarts.
2026-06-23 21:42:39 +03:30
b0fcb7a6c3 main: pass correct APIEndpoint to NewBotAPIWithClient 2026-06-23 21:00:27 +03:30
3aaf5b326d main: use default HTTP client (proxy via env) 2026-06-23 20:58:52 +03:30
f6d6e994aa Docker: proxy args for go mod download (temporary) 2026-06-23 20:53:58 +03:30
7d03a4d8fc Docker: use explicit Go 1.26.4 image to match go.mod 2026-06-23 20:52:52 +03:30
0a9175add4 handlers: replace fmt.Errorf with errors.New to satisfy vet
All checks were successful
CI / build (push) Successful in 55s
2026-06-23 20:50:03 +03:30
0909cbac7d CI: add HTTP_PROXY/HTTPS_PROXY to reach Golang toolchain (temporary solution)
Some checks failed
CI / build (push) Failing after 40s
2026-06-23 20:48:21 +03:30
a447e127ec CI: install git and ca-certificates for VCS module fetch
Some checks failed
CI / build (push) Failing after 8m34s
2026-06-23 20:38:28 +03:30
316d8446ee CI: set GOPROXY=direct to avoid proxy block
Some checks failed
CI / build (push) Failing after 4m54s
2026-06-23 20:32:57 +03:30
070b59f976 Docker: update builder to Go 1.26 to match go.mod
Some checks failed
CI / build (push) Has been cancelled
2026-06-23 20:31:42 +03:30
292516fcd3 CI: use Go 1.26 to match go.mod toolchain
Some checks failed
CI / build (push) Has been cancelled
2026-06-23 20:27:53 +03:30
5666170824 CI: remove sudo from install step (root in act)
Some checks failed
CI / build (push) Failing after 19s
2026-06-23 20:26:08 +03:30
9ed38c7514 CI: install Node.js before using JavaScript actions
Some checks failed
CI / build (push) Failing after 2s
2026-06-23 20:25:22 +03:30
54 changed files with 7858 additions and 929 deletions

View File

@@ -1,7 +1,9 @@
BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN
HTTP_PROXY=
HTTPS_PROXY=
DB_PATH=db.sqlite3
BOT_WEBHOOK_URL=
BOT_LISTEN=:8080
BOT_TLS_CERT=
BOT_TLS_KEY=
BOT_ALLOWED_USERS=

View File

@@ -8,11 +8,20 @@ on:
jobs:
build:
runs-on: ubuntu-latest
env:
# temporary: proxy required due to firewall blocks
HTTP_PROXY: http://192.168.100.2:10808
HTTPS_PROXY: http://192.168.100.2:10808
steps:
- name: Install system dependencies (Node.js, Git)
run: |
apt-get update
apt-get install -y nodejs git ca-certificates
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.26"
- name: Check formatting
run: test -z "$(gofmt -d .)" || exit 1

1
.gitignore vendored
View File

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

View File

@@ -1,39 +0,0 @@
# Agents.md
## Current Task
Build a simple Telegram timetracking bot in Go.
## Context Summary
- **User requirements**: only clockin / clockout, autoinfer breaks, onsite/remote flag (ask after first clockin), i18n (fa/en), SQLite storage, monthly .xlsx export, HTTP proxy support, Docker, CI via Gitea.
- **Design decisions**: single user, event pairing logic, minimal SQLite schema, ondemand Excel download, Docker multistage, CI/CD workflow.
- **Implementation plan**: 11 phases (scaffold → Docker → CI), with edgecase handling (multiple clockouts, missed clockout, midnight sessions, timezone).
## Edge Cases Covered
- Multiple clockouts → treat middle clockout as break start.
- No clockout → blocked unless clockin first.
- Midnight or overnight sessions → attributed to originating day.
- Unpaired final event → indicates currently clockedin.
- Remote work flag set after first clockin (default onsite).
## Edge Cases Checklist
- [ ] Clockin/out validation (no duplicate in/out).
- [ ] Onsite/remote flag storage.
- [ ] i18n message loading (fa/en).
- [ ] Excel file saved monthly and on demand.
- [ ] Proxy configuration respected for all outbound traffic.
## Next Steps (TODO)
1. Scaffold repo & config loader.
2. Initialize SQLite DB and event models.
3. Implement Telegram bot core with proxy transport.
4. Add clockin / clockout handlers with validation.
5. Build event pairing & daily/weekly/monthly totals logic.
6. Create monthly .xlsx template and generation.
7. Implement dayoff handling and editevent UI.
8. Add Dockerfile, dockercompose, and Gitea CI workflow.
9. Polish i18n, error handling, and edgecase tests.

View File

@@ -1,20 +1,24 @@
FROM golang:1.22-alpine AS builder
FROM golang:1.26.4-alpine3.23 AS builder
WORKDIR /src
ARG HTTP_PROXY
ARG HTTPS_PROXY
ENV HTTP_PROXY=${HTTP_PROXY}
ENV HTTPS_PROXY=${HTTPS_PROXY}
COPY go.mod go.sum ./
RUN go mod download
COPY . .
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
WORKDIR /data
COPY --from=builder /usr/local/bin/worktime-bot /usr/local/bin/worktime-bot
COPY --from=builder /src/db/schema.sql ./db/schema.sql
VOLUME ["/data"]
CMD ["worktime-bot"]

View File

@@ -1,24 +1,33 @@
.PHONY: build run test clean docker docker-run lint vet
BINARY=worktime-bot
BUILD_DIR=.build
build: $(BUILD_DIR)/$(BINARY)
.PHONY: all build run test clean fmt tidy vet check docker docker-run
$(BUILD_DIR)/$(BINARY): cmd/bot/*.go internal/**/*.go pkg/**/*.go go.mod go.sum
mkdir -p $(BUILD_DIR)
all: build test check
build:
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/bot/
run: build
$(BUILD_DIR)/$(BINARY)
run-dev:
go run ./cmd/bot/
test:
go test ./...
lint: vet
fmt:
go fmt ./...
tidy:
go mod tidy
vet:
go vet ./...
check: fmt vet
clean:
rm -rf $(BUILD_DIR)
@@ -27,6 +36,3 @@ docker:
docker-run: docker
docker compose up -d
.PHONY: all
all: build test lint

203
README.md Normal file
View File

@@ -0,0 +1,203 @@
# 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.
## Quick Start
```bash
cp .env.example .env
# edit .env — set BOT_TOKEN and BOT_ALLOWED_USERS
docker compose up -d
```
## Environment Variables
| Variable | Default | Purpose |
| ------------------- | ------------ | ----------------------------------------------------- |
| `BOT_TOKEN` | — | Telegram Bot API token **(required)** |
| `BOT_ALLOWED_USERS` | (all) | Comma-separated Telegram user IDs (empty = allow all) |
| `HTTP_PROXY` | — | Outbound HTTP proxy for Telegram API |
| `HTTPS_PROXY` | — | Outbound HTTPS proxy for Telegram API |
| `DB_PATH` | `db.sqlite3` | Path to SQLite database file |
| `BOT_WEBHOOK_URL` | — | Set for webhook mode (omit for long-polling) |
| `BOT_LISTEN` | `:8080` | Listen address for webhook mode |
| `BOT_TLS_CERT` | — | TLS certificate path (webhook HTTPS) |
| `BOT_TLS_KEY` | — | TLS key path (webhook HTTPS) |
## Calendar Support
Users can choose between three calendars via Settings:
- **Gregorian** — standard international calendar
- **Jalali** (Persian/Solar Hijri) — official calendar of Iran and Afghanistan
- **Hijri** (Islamic lunar) — used in many Muslim-majority countries
The history view, export month picker, and date displays all adapt to the user's chosen calendar. The `/export` and `/edit` commands accept dates in the user's calendar. Internally, all dates are stored as Gregorian.
## Features
### RPG Progression System
Every second of tracked work earns XP. Level up as you accumulate XP with a quadratic curve. Maintain consecutive workdays to build a streak for bonus XP.
| Feature | Details |
| ------------ | --------------------------------------------------------------------------------------------------------- |
| XP rate | 1 XP per second worked |
| Level curve | `totalXP(n) = n * (n + 1) * 50` (Level 10 = 5,500 XP, Level 27 = 37,800 XP) |
| Streak bonus | `streak × 50` XP per session, capped at 1,000 XP |
| Achievements | 12 achievements for milestones (early bird, night owl, streaks, hours, levels, overtime, weekend warrior) |
Use `/rpg` to view your stats and achievements. Enable/disable via Settings or `/rpgtoggle`.
### WorkTime League
Compete with other users on the leaderboard. Sortable by XP, Level, Streak, or Hours. Opt in via Settings or `/leaguetoggle`. Users without a display name appear as "Anonymous".
### Salary Estimation
Estimate monthly earnings based on tracked work time. Configure your hourly rate with `/setrate <amount>` and currency symbol with `/setcurrency <symbol> <code>`. View the estimate with `/salary`.
### Burnout Assessment
Get a 0-100 burnout score based on consecutive workdays, long hours, late-night sessions, break ratio, and work volume trends. Use `/burnout`.
### Inline History Editing
Browse past days with the History button, then tap any event to edit its time, type, work type, or note. Add missing events or delete incorrect ones. Changes are reflected immediately in XP, streaks, and totals.
### Work Types
Categorize your work sessions (e.g., Remote, Onsite, Meeting). The current work type is assigned to new clock-ins. Change via the inline menu or `/worktype`.
## Usage
**Persistent reply keyboard** at the bottom of the chat: `Clock In` / `Clock Out`
**Inline menu** on `/start`:
| Button | Action |
| -------------------- | --------------------------------- |
| Clock In / Clock Out | Record work event |
| Report | Today's work & break summary |
| Export | Pick a month → download `.xlsx` |
| History | Browse past days in calendar view |
| Work Type | Switch work type (Remote/Onsite) |
| Day Off | Mark today as day off |
| RPG / Achievements | View RPG stats and achievements |
| League | View leaderboard |
| Salary | View monthly salary estimate |
| Burnout | View burnout assessment |
| Settings | Timezone, calendar, accent, more |
### Commands
| Command | Action |
| --------------------------- | ----------------------------------------------------------- |
| `/start` | Show inline menu |
| `/settings` | Open settings menu |
| `/clockin` | Record clock-in |
| `/clockout` | Record clock-out |
| `/worktype` | Change current work type |
| `/report` | Today's work & break summary |
| `/dayoff` | Toggle today as day off |
| `/export [YYYY-MM]` | Export monthly report (optional `YYYY-MM` in your calendar) |
| `/summary [YYYY-MM]` | Show monthly summary (optional `YYYY-MM`) |
| `/history` | View calendar history (inline menu also) |
| `/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 |
| `/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) |
| `/setaccent` | Select export accent color |
| `/setreporttime HH:MM` | Set daily auto-report time |
| `/reporttoggle` | Enable/disable daily auto-report |
| `/rpg` | View RPG stats |
| `/rpgtoggle` | Enable/disable RPG system |
| `/league` | View WorkTime League leaderboard |
| `/leaguetoggle` | Opt in/out of the league |
| `/salary` | View monthly salary estimate |
| `/setrate <amount>` | Set hourly rate for salary estimation |
| `/setcurrency <sym> <code>` | Set currency (e.g. `$ USD`, `T Toman`, `€ EUR`) |
| `/burnout` | View burnout assessment |
| `/setusername <name>` | Set display name for the league |
## Daily Report
A daily summary is sent automatically at the user's configured report time (default `23:00`). The scheduler checks every 30 seconds.
## How Break Time Works
The gap between a clock-out and the next clock-in is counted as break. Gaps shorter than the **min break threshold** (default 5 minutes) are absorbed into work time. Use `/setbreak <minutes>` to adjust per day (0-480).
```
09:00 Clock In → work starts
12:00 Clock Out → work: 3h, break starts
13:00 Clock In → break: 1h, work resumes
17:00 Clock Out → work: 4h
total: 7h work, 1h break
```
## Export
The `/export` command opens a month picker. Select any month in your calendar and download an `.xlsx` file with columns: Date, Clock In, Clock Out, Type, Work, Break.
Four color themes are available in settings: **Ocean**, **Beach**, **Rose**, **Catppuccin**.
## Project Structure
```
├── cmd/bot/
│ ├── main.go Entrypoint, polling/webhook, scheduler
│ └── webhook.go Webhook mode with TLS support
├── internal/
│ ├── domain/ Pure business logic, no external dependencies
│ │ ├── types.go Core domain types (User, Event, Day, RPGStats, ...)
│ │ ├── constants.go App-wide constants and thresholds
│ │ ├── dateutil.go Gregorian/Jalali/Hijri calendar conversions
│ │ ├── totals.go Break/work computation state machine
│ │ ├── rpg.go XP curves, levels, streaks
│ │ ├── burnout.go Burnout trend computation
│ │ ├── salary.go Salary estimation, currency helpers
│ │ └── sanitize.go Input sanitization helpers
│ ├── handler/ Telegram bot handlers (one per feature)
│ │ ├── handler.go Core handler, routing, helpers
│ │ ├── clock.go Clock in/out, day off, report
│ │ ├── calendar.go History view and inline event editing
│ │ ├── export.go Excel (.xlsx) report generation
│ │ ├── rpg.go RPG stats, achievements, aggregates
│ │ ├── league.go WorkTime League leaderboard
│ │ ├── salary.go Salary estimation view
│ │ ├── burnout.go Burnout assessment view
│ │ ├── settings.go Settings menu with inline pickers
│ │ ├── summary.go Monthly summary
│ │ ├── report.go Daily report builder, status text
│ │ ├── note.go Note command, edit command
│ │ ├── worktype.go Work type selection
│ │ └── keyboard.go Inline keyboard builders
│ └── repo/ Data access layer
│ ├── interface.go Repository interface
│ ├── store.go SQLite implementation
│ └── migrations.go Goose-managed migration runner
│ └── migrations/ SQL migration files
│ ├── 001_init.sql Initial schema
│ └── 002_features.sql RPG, achievements, user_settings
├── .gitea/workflows/
│ └── ci.yml Gitea Actions CI
├── Dockerfile
├── docker-compose.yml
├── Makefile
├── .env.example
└── README.md
```
## Build & Run
```bash
# Local (requires Go 1.26+ and CGo-free SQLite)
make run
# Docker
docker compose up -d --build
```

View File

@@ -1,70 +1,160 @@
package main
import (
"log"
"net/http"
"net/url"
"context"
"log/slog"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/joho/godotenv"
"worktimeBot/internal/bot"
"worktimeBot/internal/db"
"worktimeBot/pkg/i18n"
"worktimeBot/internal/handler"
"worktimeBot/internal/repo"
)
func main() {
// Load .env file if it exists (ignored when running inside Docker with env vars)
_ = godotenv.Load()
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
})))
godotenv.Load()
token := os.Getenv("BOT_TOKEN")
proxyURL := os.Getenv("HTTP_PROXY")
dbPath := getEnvDefault("DB_PATH", "db.sqlite3")
// Build HTTP client with optional proxy
var httpClient *http.Client
if proxyURL != "" {
parsed, err := url.Parse(proxyURL)
if err != nil {
log.Fatal(err)
store, err := repo.NewStore(dbPath)
if err != nil {
slog.Error("failed to open database", "path", dbPath, "error", err)
os.Exit(1)
}
allowedUsers := parseAllowedUsers(os.Getenv("BOT_ALLOWED_USERS"))
var h *handler.Handler
dispatch := func(ctx context.Context, b *tgbot.Bot, update *models.Update) {
if update.Message != nil {
h.HandleMessage(ctx, update.Message)
}
if update.CallbackQuery != nil {
h.HandleCallback(ctx, update.CallbackQuery)
}
tr := &http.Transport{Proxy: http.ProxyURL(parsed)}
httpClient = &http.Client{Transport: tr}
} else {
httpClient = http.DefaultClient
}
botAPI, err := tgbotapi.NewBotAPIWithClient(token, "", httpClient)
b, err := tgbot.New(token, tgbot.WithDefaultHandler(dispatch))
if err != nil {
log.Fatal(err)
slog.Error("failed to create bot", "error", err)
os.Exit(1)
}
dbStore, err := db.NewStore(dbPath)
if err != nil {
log.Fatal(err)
}
h = handler.NewHandler(b, store, allowedUsers)
translator, _ := i18n.NewTranslator("en")
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
handler := bot.NewHandler(botAPI, dbStore, translator)
webhookURL := os.Getenv("BOT_WEBHOOK_URL")
if webhookURL != "" {
startWebhook(botAPI, handler, webhookURL)
if url := os.Getenv("BOT_WEBHOOK_URL"); url != "" {
startWebhook(ctx, b, h, url)
} else {
// Fallback to longpolling
u := tgbotapi.NewUpdate(0)
u.Timeout = 30
updates := botAPI.GetUpdatesChan(u)
for update := range updates {
if update.Message != nil {
handler.HandleMessage(update)
}
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
slog.Warn("failed to delete webhook", "error", err)
}
go dailyReportScheduler(ctx, h, store)
slog.Info("bot started",
"allowed_users", len(allowedUsers),
"mode", "polling",
)
b.Start(ctx)
}
store.Close()
}
// dailyReportScheduler runs a periodic ticker that triggers daily report checks every 30 seconds.
func dailyReportScheduler(ctx context.Context, h *handler.Handler, store repo.Repository) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkDailyReports(ctx, h, store)
}
}
}
// checkDailyReports iterates all users and sends a daily report to those whose report time has arrived.
func checkDailyReports(ctx context.Context, h *handler.Handler, store repo.Repository) {
users, err := store.GetAllUsers()
if err != nil {
slog.Error("report scheduler: get users", "error", err)
return
}
now := time.Now().UTC()
for _, u := range users {
if !u.IsActive {
continue
}
st, err := store.GetOrCreateSettings(u.ID)
if err != nil || !st.ReportEnabled {
continue
}
loc, err := time.LoadLocation(u.Timezone)
if err != nil {
slog.Error("invalid timezone for user, skipping report", "chat_id", u.ChatID, "timezone", u.Timezone, "error", err)
continue
}
localNow := now.In(loc)
today := localNow.Format("2006-01-02")
currentHHMM := localNow.Format("15:04")
if currentHHMM != st.ReportTime {
continue
}
if st.LastReportDate == today {
continue
}
slog.Info("sending daily report",
"user_id", u.ID, "chat_id", u.ChatID, "timezone", u.Timezone,
)
h.SendDailyReport(ctx, u.ID, u.ChatID)
store.MarkReportSent(u.ID, today)
}
}
// parseAllowedUsers parses a comma-separated list of Telegram user IDs into a set.
func parseAllowedUsers(raw string) map[int64]bool {
m := make(map[int64]bool)
for _, s := range strings.Split(raw, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
id, err := strconv.ParseInt(s, 10, 64)
if err == nil {
m[id] = true
} else {
slog.Warn("invalid user ID in BOT_ALLOWED_USERS", "value", s)
}
}
return m
}
// getEnvDefault returns the environment variable value or a default if unset or empty.
func getEnvDefault(key, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val

View File

@@ -1,61 +1,65 @@
package main
import (
"log"
"context"
"log/slog"
"net/http"
"os"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
tgbot "github.com/go-telegram/bot"
"worktimeBot/internal/bot"
"worktimeBot/internal/handler"
)
// startWebhook configures the Telegram webhook and starts the HTTP(S) listener.
func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL string) {
// Delete any previously set webhook
if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil {
log.Fatal("DeleteWebhook:", err)
// startWebhook registers the webhook with Telegram and starts the HTTPS/HTTP listener loop.
func startWebhook(ctx context.Context, b *tgbot.Bot, h *handler.Handler, webhookURL string) {
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
slog.Warn("failed to delete webhook", "error", err)
}
// Set the new webhook
wh, err := tgbotapi.NewWebhook(webhookURL)
if err != nil {
log.Fatal("NewWebhook:", err)
}
if _, err := botAPI.Request(wh); err != nil {
log.Fatal("SetWebhook:", err)
if _, err := b.SetWebhook(ctx, &tgbot.SetWebhookParams{URL: webhookURL}); err != nil {
slog.Error("set webhook", "error", err)
os.Exit(1)
}
// Register the webhook endpoint and get the updates channel
updates := botAPI.ListenForWebhook("/webhook")
// Determine where to listen
listenAddr := getEnvDefault("BOT_LISTEN", ":8080")
tlsCert := os.Getenv("BOT_TLS_CERT")
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 != "" {
log.Printf("Starting HTTPS webhook on %s", listenAddr)
slog.Info("starting HTTPS webhook", "addr", listenAddr)
go func() {
if err := http.ListenAndServeTLS(listenAddr, tlsCert, tlsKey, nil); err != nil {
log.Fatal("HTTPS server:", err)
if err := server.ListenAndServeTLS(tlsCert, tlsKey); err != nil && err != http.ErrServerClosed {
slog.Error("HTTPS server", "error", err)
os.Exit(1)
}
}()
} else {
log.Printf("Starting HTTP webhook on %s (TLS handled by reverse proxy)", listenAddr)
slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr)
go func() {
if err := http.ListenAndServe(listenAddr, nil); err != nil {
log.Fatal("HTTP server:", err)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("HTTP server", "error", err)
os.Exit(1)
}
}()
}
log.Printf("Webhook registered at %s", webhookURL)
// Process incoming updates
for update := range updates {
if update.Message != nil {
handler.HandleMessage(update)
}
}
slog.Info("webhook registered", "url", webhookURL)
b.StartWebhook(ctx)
}

View File

@@ -1,26 +0,0 @@
CREATE TABLE events (
id INTEGER PRIMARY KEY,
event_type TEXT NOT NULL
CHECK (event_type IN ('in', 'out')),
occurred_at INTEGER NOT NULL UNIQUE
CHECK (occurred_at > 0),
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
DEFAULT (unixepoch())
);
CREATE INDEX idx_events_occurred_at
ON events(occurred_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
CREATE TABLE days_off (
date TEXT PRIMARY KEY
CHECK (date = strftime('%Y-%m-%d', date)),
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
DEFAULT (unixepoch())
);

View File

@@ -1,15 +1,26 @@
services:
worktime-bot:
build: .
build:
context: .
args:
HTTP_PROXY: ${HTTP_PROXY}
HTTPS_PROXY: ${HTTPS_PROXY}
env_file:
- .env
environment:
- BOT_TOKEN=${BOT_TOKEN}
- HTTP_PROXY=${HTTP_PROXY-}
- DB_PATH=/data/db.sqlite3
- HTTP_PROXY=${HTTP_PROXY}
- HTTPS_PROXY=${HTTPS_PROXY}
- DB_PATH=${DB_PATH}
- TZ=${TZ}
volumes:
- worktime_data:/data
networks:
default:
name: proxy_net
external: true
volumes:
worktime_data:
driver: local

15
go.mod
View File

@@ -3,7 +3,7 @@ module worktimeBot
go 1.26.4
require (
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/go-telegram/bot v1.21.0
github.com/joho/godotenv v1.5.1
github.com/xuri/excelize/v2 v2.10.1
modernc.org/sqlite v1.53.0
@@ -12,18 +12,23 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pressly/goose/v3 v3.27.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.36.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

20
go.sum
View File

@@ -2,8 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/go-telegram/bot v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps=
github.com/go-telegram/bot v1.21.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -14,16 +14,24 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
@@ -34,14 +42,20 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -49,6 +63,8 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -1,76 +0,0 @@
package bot
import (
"fmt"
"time"
"github.com/xuri/excelize/v2"
"worktimeBot/internal/db"
)
// GenerateMonthlyReport creates an Excel spreadsheet for the given month.
func GenerateMonthlyReport(store *db.Store, year int, month time.Month) ([]byte, error) {
f := excelize.NewFile()
defer func() { _ = f.Close() }()
sheet := "Report"
// Create a new sheet
index, err := f.NewSheet(sheet)
if err != nil {
return nil, err
}
f.SetActiveSheet(index)
// Header row
headers := []string{"Date", "Work Duration (h)", "Pairs"}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
if err := f.SetCellValue(sheet, cell, h); err != nil {
return nil, err
}
}
row := 2
loc := time.Now().Location()
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc)
lastDay := firstDay.AddDate(0, 1, -1)
for d := firstDay; d.Before(lastDay.AddDate(0, 0, 1)); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02")
isOff, err := store.IsDayOff(dateStr)
if err != nil {
return nil, err
}
if isOff {
continue // skip dayoffs
}
events, err := store.EventsForDay(dateStr)
if err != nil {
return nil, err
}
totals := ComputeDailyTotals(events)
hours := float64(totals.TotalSeconds) / 3600.0
cellDate, _ := excelize.CoordinatesToCellName(1, row)
cellHours, _ := excelize.CoordinatesToCellName(2, row)
cellPairs, _ := excelize.CoordinatesToCellName(3, row)
if err := f.SetCellValue(sheet, cellDate, dateStr); err != nil {
return nil, err
}
if err := f.SetCellValue(sheet, cellHours, fmt.Sprintf("%.2f", hours)); err != nil {
return nil, err
}
if err := f.SetCellValue(sheet, cellPairs, totals.PairCount); err != nil {
return nil, err
}
row++
}
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}

View File

@@ -1,190 +0,0 @@
package bot
import (
"database/sql"
"fmt"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"worktimeBot/internal/db"
"worktimeBot/pkg/i18n"
)
type Handler struct {
Bot *tgbotapi.BotAPI
DB *db.Store
Translator *i18n.Translator
}
func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, tr *i18n.Translator) *Handler {
return &Handler{Bot: bot, DB: store, Translator: tr}
}
// HandleMessage routes incoming messages.
func (h *Handler) HandleMessage(update tgbotapi.Update) {
msg := update.Message
switch msg.Text {
case "/clockin":
if err := h.handleClockIn(); err != nil {
h.replyWithError(msg.Chat.ID, err.Error())
} else {
h.replyWithText(msg.Chat.ID, "clock_in")
}
case "/clockout":
if err := h.handleClockOut(); err != nil {
h.replyWithError(msg.Chat.ID, err.Error())
} else {
h.replyWithText(msg.Chat.ID, "clock_out")
}
case "/remote":
h.toggleRemote(msg)
case "/report":
h.handleReport(msg)
case "/export":
h.handleExport(msg)
case "/dayoff":
h.handleDayOff(msg)
default:
h.replyWithText(msg.Chat.ID, "unknown_command")
}
}
// handleClockIn validates and records a clockin event.
func (h *Handler) handleClockIn() error {
last, err := h.DB.GetLastEvent()
if err != nil && err != sql.ErrNoRows {
return err
}
if last != nil && last.EventType == "in" {
return fmt.Errorf(h.Translator.Get("already_clocked_in", "en"))
}
// Set default remote flag after first clockin.
if _, err := h.DB.GetSetting("remote_flag"); err != nil {
_ = h.DB.SetSetting("remote_flag", "onsite")
}
return h.DB.CreateEvent("in", time.Now().Unix(), "")
}
// handleClockOut validates and records a clockout event.
func (h *Handler) handleClockOut() error {
last, err := h.DB.GetLastEvent()
if err != nil {
return err
}
if last == nil {
return fmt.Errorf(h.Translator.Get("error", "en"))
}
switch last.EventType {
case "in":
return h.DB.CreateEvent("out", time.Now().Unix(), "")
case "out":
return fmt.Errorf(h.Translator.Get("already_clocked_out", "en"))
default:
return fmt.Errorf(h.Translator.Get("error", "en"))
}
}
// toggleRemote switches the remote/onsite flag.
func (h *Handler) toggleRemote(msg *tgbotapi.Message) {
current, err := h.DB.GetSetting("remote_flag")
if err != nil {
current = "onsite"
}
newMode := "remote"
if current == "remote" {
newMode = "onsite"
}
if err := h.DB.SetSetting("remote_flag", newMode); err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
key := "remote_switched_on"
if newMode == "onsite" {
key = "remote_switched_off"
}
h.replyWithText(msg.Chat.ID, key)
}
// handleReport sends daily work time summary.
func (h *Handler) handleReport(msg *tgbotapi.Message) {
today := time.Now().Format("2006-01-02")
events, err := h.DB.EventsForDay(today)
if err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
totals := ComputeDailyTotals(events)
if totals.PairCount == 0 {
h.replyWithText(msg.Chat.ID, "total_work_time")
return
}
workStr := formatDuration(totals.TotalSeconds)
breakStr := formatDuration(totals.BreakSeconds)
text := fmt.Sprintf("Work: %s | Break: %s", workStr, breakStr)
reply := tgbotapi.NewMessage(msg.Chat.ID, text)
_, _ = h.Bot.Send(reply)
}
// formatDuration converts seconds to a humanreadable string.
func formatDuration(seconds int64) string {
hours := seconds / 3600
mins := (seconds % 3600) / 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}
// handleExport generates and sends a monthly Excel report.
func (h *Handler) handleExport(msg *tgbotapi.Message) {
now := time.Now()
year, month := now.Year(), now.Month()
data, err := GenerateMonthlyReport(h.DB, year, month)
if err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
doc := tgbotapi.NewDocument(msg.Chat.ID, tgbotapi.FileBytes{
Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")),
Bytes: data,
})
if _, err := h.Bot.Send(doc); err != nil {
h.replyWithError(msg.Chat.ID, "error")
}
}
// handleDayOff toggles today as a day off.
func (h *Handler) handleDayOff(msg *tgbotapi.Message) {
today := time.Now().Format("2006-01-02")
isOff, err := h.DB.IsDayOff(today)
if err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
if isOff {
if err := h.DB.RemoveDayOff(today); err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
h.replyWithText(msg.Chat.ID, "dayoff_removed")
} else {
if err := h.DB.SetDayOff(today, ""); err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
h.replyWithText(msg.Chat.ID, "dayoff_set")
}
}
func (h *Handler) replyWithText(chatID int64, key string) {
text := h.Translator.Get(key, "en")
reply := tgbotapi.NewMessage(chatID, text)
_, _ = h.Bot.Send(reply)
}
func (h *Handler) replyWithError(chatID int64, key string) {
text := h.Translator.Get(key, "en")
reply := tgbotapi.NewMessage(chatID, text)
_, _ = h.Bot.Send(reply)
}

View File

@@ -1,80 +0,0 @@
package bot
import (
"sort"
"worktimeBot/internal/db"
)
type State int
const (
StateIdle State = iota
StateWorking
StateOnBreak
)
// DailyTotals holds aggregated times for a single day.
type DailyTotals struct {
TotalSeconds int64
BreakSeconds int64
PairCount int
}
// ComputeDailyTotals pairs events with break inference.
// Multiout sequences: first out ends work, second out starts break,
// next in ends break, next out ends work, etc.
func ComputeDailyTotals(events []db.Event) DailyTotals {
if len(events) == 0 {
return DailyTotals{}
}
sorted := make([]db.Event, len(events))
copy(sorted, events)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].OccurredAt < sorted[j].OccurredAt
})
var workTotal, breakTotal int64
var workStart, breakStart int64
pairs := 0
state := StateIdle
for _, e := range sorted {
switch {
case e.EventType == "in":
switch state {
case StateIdle, StateOnBreak:
if state == StateOnBreak {
breakTotal += e.OccurredAt - breakStart
breakStart = 0
}
workStart = e.OccurredAt
state = StateWorking
// Working → ignore duplicate in (shouldn't happen)
case StateWorking:
// already working, ignore
}
case e.EventType == "out":
switch state {
case StateWorking:
// End of work period
workTotal += e.OccurredAt - workStart
workStart = 0
pairs++
state = StateIdle
case StateIdle:
// Second consecutive out → break start
breakStart = e.OccurredAt
state = StateOnBreak
case StateOnBreak:
// Duplicate break start ignore
}
}
}
return DailyTotals{
TotalSeconds: workTotal,
BreakSeconds: breakTotal,
PairCount: pairs,
}
}

View File

@@ -1,24 +0,0 @@
package config
import (
"encoding/json"
"os"
)
type Config struct {
BotToken string `json:"bot_token"`
ProxyAddr string `json:"proxy_address"`
LogLevel string `json:"log_level"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}

View File

@@ -1,152 +0,0 @@
package db
import (
"database/sql"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
const driverName = "sqlite"
var schemaPath = filepath.Join("db", "schema.sql")
// Store wraps the sql.DB connection.
type Store struct {
db *sql.DB
}
// NewStore opens the SQLite DB and runs the schema file.
func NewStore(path string) (*Store, error) {
db, err := sql.Open(driverName, path)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
if err := execSchema(db, schemaPath); err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func execSchema(db *sql.DB, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = db.Exec(string(data))
return err
}
// CreateEvent inserts a new event.
func (s *Store) CreateEvent(eventType string, occurredAt int64, note string) error {
query := `
INSERT INTO events (event_type, occurred_at, note)
VALUES (?, ?, ?)
`
_, err := s.db.Exec(query, eventType, occurredAt, note)
return err
}
// GetLastEvent returns the most recent event.
func (s *Store) GetLastEvent() (*Event, error) {
row, err := s.db.Query(`
SELECT id, event_type, occurred_at, note, created_at
FROM events
ORDER BY occurred_at DESC
LIMIT 1
`)
if err != nil {
return nil, err
}
defer row.Close()
if !row.Next() {
return nil, sql.ErrNoRows
}
var e Event
if err := row.Scan(&e.ID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
return nil, err
}
return &e, nil
}
// GetSetting returns a setting value by key.
func (s *Store) GetSetting(key string) (string, error) {
var value string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
if err != nil {
return "", err
}
return value, nil
}
// SetSetting stores or updates a setting.
func (s *Store) SetSetting(key, value string) error {
query := `
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value
`
_, err := s.db.Exec(query, key, value)
return err
}
// SetDayOff adds or updates a dayoff record.
func (s *Store) SetDayOff(date, reason string) error {
query := `
INSERT INTO days_off (date, reason) VALUES (?, ?)
ON CONFLICT(date) DO UPDATE SET reason=excluded.reason
`
_, err := s.db.Exec(query, date, reason)
return err
}
// RemoveDayOff deletes a dayoff record.
func (s *Store) RemoveDayOff(date string) error {
_, err := s.db.Exec(`DELETE FROM days_off WHERE date = ?`, date)
return err
}
// IsDayOff returns true if the given date (YYYY-MM-DD) is marked as day off.
func (s *Store) IsDayOff(date string) (bool, error) {
var exists int
err := s.db.QueryRow(`SELECT COUNT(1) FROM days_off WHERE date = ?`, date).Scan(&exists)
if err != nil {
return false, err
}
return exists > 0, nil
}
// EventsForDay returns all events for a given day (ISO date string).
func (s *Store) EventsForDay(day string) ([]Event, error) {
rows, err := s.db.Query(`
SELECT id, event_type, occurred_at, note, created_at
FROM events
WHERE strftime('%Y-%m-%d', occurred_at, 'unixepoch') = ?
ORDER BY occurred_at ASC
`, day)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
if err := rows.Scan(&e.ID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// Event represents a row from the events table.
type Event struct {
ID int64 `json:"id"`
EventType string `json:"event_type"`
OccurredAt int64 `json:"occurred_at"`
Note string `json:"note"`
CreatedAt int64 `json:"created_at"`
}

View File

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

View File

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

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

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

View File

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

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

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

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

@@ -0,0 +1,371 @@
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 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) {
if got := SanitizeDisplayName(" Alice "); got != "Alice" {
t.Errorf("SanitizeDisplayName trim: got %q", got)
}
if got := SanitizeDisplayName("Ali\x00ce"); got != "Alice" {
t.Errorf("SanitizeDisplayName control: got %q", got)
}
if got := SanitizeDisplayName(""); got != "" {
t.Errorf("SanitizeDisplayName empty: got %q", got)
}
}
func TestParseCurrencyInput(t *testing.T) {
cases := []struct {
input string
want string
ok bool
}{
{"$ USD", "$ USD", true},
{"T Toman", "T Toman", true},
{"IRR Rial", "IRR Rial", true},
{"\u20AC EUR", "\u20AC EUR", true},
{"", "", false},
{"$", "", false},
{" ", "", false},
{"$$$ TooLongCode", "", false},
}
for _, c := range cases {
got, errMsg := ParseCurrencyInput(c.input)
if c.ok && (got != c.want || errMsg != "") {
t.Errorf("ParseCurrencyInput(%q) = (%q, %q), want (%q, \"\")", c.input, got, errMsg, c.want)
}
if !c.ok && errMsg == "" {
t.Errorf("ParseCurrencyInput(%q) = (%q, \"\"), want error", c.input, got)
}
}
}
func TestComputeTrendFromAvgs(t *testing.T) {
cases := []struct {
recent, older float64
wantPts int
wantDirSub string
}{
{0, 0, 5, "stable"},
{100, 0, 15, "new"},
{0, 100, 0, "-100"},
{121, 100, 15, "+21"},
{100, 100, 5, "stable"},
{100, 130, 0, "-23"},
}
for _, c := range cases {
pts, dir := ComputeTrendFromAvgs(c.recent, c.older)
if pts != c.wantPts {
t.Errorf("ComputeTrendFromAvgs(%.0f, %.0f) pts = %d, want %d", c.recent, c.older, pts, c.wantPts)
}
if !strings.Contains(dir, c.wantDirSub) {
t.Errorf("ComputeTrendFromAvgs(%.0f, %.0f) dir = %q, want containing %q", c.recent, c.older, dir, c.wantDirSub)
}
}
}
func TestComputeStreakFromDates_Empty(t *testing.T) {
cur, longest := ComputeStreakFromDates(nil, "2026-06-25")
if cur != 0 || longest != 0 {
t.Errorf("empty dates: got (%d, %d), want (0, 0)", cur, longest)
}
}
func TestComputeStreakFromDates_CurrentOnly(t *testing.T) {
dates := []string{"2026-06-25", "2026-06-26"}
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
if cur != 2 {
t.Errorf("current streak = %d, want 2", cur)
}
if longest != 2 {
t.Errorf("longest streak = %d, want 2", longest)
}
}
func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) {
dates := []string{"2026-06-23", "2026-06-25", "2026-06-26"}
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
if cur != 2 {
t.Errorf("current streak = %d, want 2 (today and yesterday)", cur)
}
if longest != 2 {
t.Errorf("longest streak = %d, want 2", longest)
}
}
func TestComputeStreakFromDates_NoEventsToday(t *testing.T) {
dates := []string{"2026-06-24", "2026-06-25"}
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
if cur != 0 {
t.Errorf("current streak = %d, want 0 (no events today)", cur)
}
if longest != 2 {
t.Errorf("longest streak = %d, want 2", longest)
}
}
func TestComputeStreakFromDates_LongStreak(t *testing.T) {
dates := make([]string, 0, 10)
for d := 1; d <= 10; d++ {
dates = append(dates, fmt.Sprintf("2026-06-%02d", d))
}
cur, longest := ComputeStreakFromDates(dates, "2026-06-10")
if cur != 10 {
t.Errorf("current streak = %d, want 10", cur)
}
if longest != 10 {
t.Errorf("longest streak = %d, want 10", longest)
}
}
func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) {
dates := []string{}
for d := 1; d <= 5; d++ {
dates = append(dates, formatDateInt(2026, 6, d))
}
dates = append(dates, "2026-06-09", "2026-06-10")
cur, longest := ComputeStreakFromDates(dates, "2026-06-10")
if cur != 2 {
t.Errorf("current streak = %d, want 2", cur)
}
if longest != 5 {
t.Errorf("longest streak = %d, want 5", longest)
}
}
func formatDateInt(y, m, d int) string {
return FormatDateForCalendar(
func() string {
s := make([]byte, 10)
s[0] = byte('0' + y/1000)
s[1] = byte('0' + (y/100)%10)
s[2] = byte('0' + (y/10)%10)
s[3] = byte('0' + y%10)
s[4] = '-'
s[5] = byte('0' + m/10)
s[6] = byte('0' + m%10)
s[7] = '-'
s[8] = byte('0' + d/10)
s[9] = byte('0' + d%10)
return string(s)
}(), "gregorian")
}

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

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

View File

@@ -0,0 +1,38 @@
package domain
import (
"strings"
"unicode"
)
// SanitizeNote cleans user-provided note text: trims whitespace, limits length,
// and removes control characters (keeps newlines).
func SanitizeNote(s string) string {
s = strings.TrimSpace(s)
if len(s) > MaxNoteLength {
s = s[:MaxNoteLength]
}
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r == ' ' {
return r
}
if unicode.IsControl(r) {
return -1
}
return r
}, s)
}
// SanitizeDisplayName cleans a display name (username) for safe rendering.
func SanitizeDisplayName(s string) string {
s = strings.TrimSpace(s)
if len(s) > MaxUsernameLength {
s = s[:MaxUsernameLength]
}
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
}

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

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

View File

@@ -0,0 +1,241 @@
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_DayStartWithOutFirst(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("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)
}
}
func TestComputeDailyTotals_MissingLastOut(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 300),
}
got := ComputeDailyTotals(events, 0, 0, 500)
if got.TotalSeconds != 300 {
t.Errorf("missing last out: TotalSeconds=%d, want 300", got.TotalSeconds)
}
}
func TestComputeDailyTotals_BreakUnderThreshold(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 250),
mkEvent("out", 400),
}
got := ComputeDailyTotals(events, 60, 0, 0)
if got.TotalSeconds != 300 {
t.Errorf("break under threshold: TotalSeconds=%d, want 300", got.TotalSeconds)
}
if got.BreakSeconds != 0 {
t.Errorf("break under threshold: BreakSeconds=%d, want 0", got.BreakSeconds)
}
}
func TestComputeDailyTotals_BreakOverThreshold(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 300),
mkEvent("out", 400),
}
got := ComputeDailyTotals(events, 60, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("break over threshold: TotalSeconds=%d, want 200", got.TotalSeconds)
}
if got.BreakSeconds != 100 {
t.Errorf("break over threshold: BreakSeconds=%d, want 100", got.BreakSeconds)
}
}
func TestComputeDailyTotals_ConsecutiveOuts(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("out", 250),
mkEvent("in", 300),
mkEvent("out", 400),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("consecutive outs: TotalSeconds=%d, want 200", got.TotalSeconds)
}
}
func TestComputeDailyTotals_ConsecutiveIns(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("in", 150),
mkEvent("out", 300),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("consecutive ins: TotalSeconds=%d, want 200", got.TotalSeconds)
}
}
func TestComputeDailyTotals_SingleInEndOfDay(t *testing.T) {
events := []Event{
mkEvent("in", 100),
}
got := ComputeDailyTotals(events, 0, 0, 500)
if got.TotalSeconds != 400 {
t.Errorf("single in with dayEnd: TotalSeconds=%d, want 400", got.TotalSeconds)
}
}
func TestComputeDailyTotals_SingleOutStartOfDay(t *testing.T) {
events := []Event{
mkEvent("out", 200),
}
got := ComputeDailyTotals(events, 0, 100, 500)
if got.TotalSeconds != 100 {
t.Errorf("single out with dayStart: TotalSeconds=%d, want 100", got.TotalSeconds)
}
}
func TestComputeDailyTotals_ZeroThreshold(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 250),
mkEvent("out", 400),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 250 {
t.Errorf("zero threshold: TotalSeconds=%d, want 250", got.TotalSeconds)
}
if got.BreakSeconds != 50 {
t.Errorf("zero threshold: BreakSeconds=%d, want 50", got.BreakSeconds)
}
}
func TestComputeDailyTotals_LargeBreak(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 10000),
mkEvent("out", 10100),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 200 {
t.Errorf("large break: TotalSeconds=%d, want 200", got.TotalSeconds)
}
if got.BreakSeconds != 9800 {
t.Errorf("large break: BreakSeconds=%d, want 9800", got.BreakSeconds)
}
}
func TestComputeDailyTotals_MultipleSessions(t *testing.T) {
events := []Event{
mkEvent("in", 100),
mkEvent("out", 200),
mkEvent("in", 300),
mkEvent("out", 400),
mkEvent("in", 500),
mkEvent("out", 600),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 300 {
t.Errorf("multiple sessions: TotalSeconds=%d, want 300", got.TotalSeconds)
}
if got.BreakSeconds != 200 {
t.Errorf("multiple sessions: BreakSeconds=%d, want 200", got.BreakSeconds)
}
}
func TestComputeDailyTotals_OnlyOut(t *testing.T) {
events := []Event{
mkEvent("out", 200),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 0 {
t.Errorf("only out no dayStart: TotalSeconds=%d, want 0", got.TotalSeconds)
}
}
func TestComputeDailyTotals_OnlyIn(t *testing.T) {
events := []Event{
mkEvent("in", 100),
}
got := ComputeDailyTotals(events, 0, 0, 0)
if got.TotalSeconds != 0 {
t.Errorf("only in no dayEnd: TotalSeconds=%d, want 0", got.TotalSeconds)
}
}

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

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

207
internal/handler/burnout.go Normal file
View File

@@ -0,0 +1,207 @@
package handler
import (
"context"
"fmt"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Command --
func (h *Handler) handleBurnout(ctx context.Context, msg *models.Message, user *domain.User) {
text := h.buildBurnoutText(user)
h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard())
}
// -- Callback --
func (h *Handler) burnoutCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildBurnoutText(user)
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Assessment --
func (h *Handler) buildBurnoutText(user *domain.User) string {
res, err := h.assessBurnout(user)
if err != nil {
return "Burnout assessment unavailable."
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Burnout Assessment\nLevel: %s (%d/100)\n\n", res.Level, res.Score))
if res.ConsecutiveDays > 0 {
b.WriteString(fmt.Sprintf(" Consecutive workdays: %d days (%d pts)\n", res.ConsecutiveDays, res.ConsecutivePts))
}
if res.WorkSeconds > 0 {
wh := float64(res.WorkSeconds) / domain.SecondsPerHour
b.WriteString(fmt.Sprintf(" Today's work: %.1f hours (%d pts)\n", wh, res.LongHoursPts))
}
if res.BreakSeconds > 0 {
br := float64(res.BreakSeconds)
total := float64(res.WorkSeconds + res.BreakSeconds)
ratio := br / total * 100
if ratio > 100 {
ratio = 100
}
b.WriteString(fmt.Sprintf(" Break ratio: %.0f%% (%d pts)\n", ratio, res.BreakRatioPts))
}
if res.WeekendPts > 0 {
b.WriteString(fmt.Sprintf(" Weekend work: yes (%d pts)\n", res.WeekendPts))
}
b.WriteString(fmt.Sprintf(" Work trend: %s (%d pts)\n", res.TrendDirection, res.TrendPts))
if res.LateNightPts > 0 {
b.WriteString(fmt.Sprintf(" Late-night work: yes (%d pts)\n", res.LateNightPts))
}
b.WriteString("\nRecommendations:\n")
switch res.Level {
case domain.BurnoutHealthy:
b.WriteString(" Keep up the balanced routine!")
case domain.BurnoutMild:
b.WriteString(" Consider taking short breaks throughout the day.")
b.WriteString("\n Make sure to disconnect after work hours.")
case domain.BurnoutModerate:
b.WriteString(" Consider taking a day off soon.")
b.WriteString("\n Review your workload distribution.")
b.WriteString("\n Ensure you are taking adequate breaks.")
case domain.BurnoutHigh:
b.WriteString(" Strongly consider taking time off.")
b.WriteString("\n Your workload patterns indicate potential strain.")
b.WriteString("\n Please prioritize rest and recovery.")
}
return b.String()
}
func (h *Handler) assessBurnout(user *domain.User) (*domain.BurnoutResult, error) {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
today := now.Format(domain.DateLayout)
result := &domain.BurnoutResult{}
stats, err := h.Repo.GetOrCreateRPGStats(user.ID)
if err == nil {
result.ConsecutiveDays = stats.CurrentStreak
}
switch {
case result.ConsecutiveDays >= 7:
result.ConsecutivePts = 25
case result.ConsecutiveDays >= 4:
result.ConsecutivePts = 15
case result.ConsecutiveDays >= 1:
result.ConsecutivePts = 5
}
todayEvents, _ := h.Repo.EventsForDayByDate(user.ID, today)
if len(todayEvents) > 0 {
day, _ := h.Repo.GetDay(user.ID, today)
if day != nil {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
totals := domain.ComputeDailyTotals(todayEvents, day.MinBreakThreshold, dayStart, now.Unix())
result.WorkSeconds = totals.TotalSeconds
result.BreakSeconds = totals.BreakSeconds
workHours := float64(totals.TotalSeconds) / domain.SecondsPerHour
switch {
case workHours >= 12:
result.LongHoursPts = 20
case workHours >= 10:
result.LongHoursPts = 15
case workHours >= 8:
result.LongHoursPts = 10
}
total := totals.TotalSeconds + totals.BreakSeconds
if total > 0 {
breakRatio := float64(totals.BreakSeconds) / float64(total) * 100
switch {
case breakRatio < 5:
result.BreakRatioPts = 15
case breakRatio < 10:
result.BreakRatioPts = 10
case breakRatio < 20:
result.BreakRatioPts = 5
}
}
if len(todayEvents) > 0 {
lastEvent := todayEvents[len(todayEvents)-1]
lt := time.Unix(lastEvent.OccurredAt, 0).In(loc)
minutes := lt.Hour()*60 + lt.Minute()
switch {
case minutes >= 22*60:
result.LateNightPts = 10
case minutes >= 20*60:
result.LateNightPts = 5
}
}
}
}
weekday := now.Weekday()
if weekday == time.Saturday || weekday == time.Sunday {
if result.WorkSeconds > 0 {
result.WeekendPts = 15
}
}
result.TrendPts, result.TrendDirection = h.computeTrend(user.ID, today, loc)
result.Score = result.ConsecutivePts + result.LongHoursPts + result.BreakRatioPts +
result.WeekendPts + result.TrendPts + result.LateNightPts
switch {
case result.Score >= 61:
result.Level = domain.BurnoutHigh
case result.Score >= 41:
result.Level = domain.BurnoutModerate
case result.Score >= 21:
result.Level = domain.BurnoutMild
default:
result.Level = domain.BurnoutHealthy
}
return result, nil
}
func (h *Handler) computeTrend(userID int64, today string, loc *time.Location) (int, string) {
t, err := time.Parse(domain.DateLayout, today)
if err != nil {
return 0, "unknown"
}
var recent, older [3]int64
for i := 0; i < 3; i++ {
d := t.AddDate(0, 0, -(i + 1)).Format(domain.DateLayout)
recent[2-i] = h.dayWorkSeconds(userID, d, loc)
}
for i := 0; i < 3; i++ {
d := t.AddDate(0, 0, -(i + 4)).Format(domain.DateLayout)
older[2-i] = h.dayWorkSeconds(userID, d, loc)
}
var recentAvg, olderAvg float64
for _, v := range recent {
recentAvg += float64(v)
}
for _, v := range older {
olderAvg += float64(v)
}
recentAvg /= 3.0
olderAvg /= 3.0
return domain.ComputeTrendFromAvgs(recentAvg, olderAvg)
}
func (h *Handler) dayWorkSeconds(userID int64, date string, loc *time.Location) int64 {
events, err := h.Repo.EventsForDayByDate(userID, date)
if err != nil || len(events) == 0 {
return 0
}
day, err := h.Repo.GetDay(userID, date)
if err != nil || day == nil {
return 0
}
y, m, d := domain.ParseGregorianDateKey(date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
return totals.TotalSeconds
}

View File

@@ -0,0 +1,542 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- History command and callback --
func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, user *domain.User) {
h.editCalendar(ctx, msg.Chat.ID, 0, 0, 0)
}
func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.editCalendar(ctx, chatID, msgID, 0, 0)
}
// -- Calendar rendering --
func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, year, month int) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
if year == 0 || month == 0 {
switch user.Calendar {
case "jalali":
jy, jm, _ := domain.GregorianToJalali(now.Year(), int(now.Month()), now.Day())
year, month = jy, jm
case "hijri":
hy, hm, _ := domain.GregorianToHijri(now.Year(), int(now.Month()), now.Day())
year, month = hy, hm
default:
year, month = now.Year(), int(now.Month())
}
}
cal := user.Calendar
cm := domain.BuildCalendarMonth(cal, year, month)
text := cm.Title
todayKey := now.Format(domain.DateLayout)
hasEvent := make(map[string]bool)
startDate, endDate := domain.MonthGregorianRange(cal, year, month)
dates, err := h.Repo.GetDistinctEventDates(user.ID, startDate, endDate)
if err == nil {
for _, d := range dates {
hasEvent[d] = true
}
}
kbRows := [][]models.InlineKeyboardButton{
{
{Text: "<", CallbackData: fmt.Sprintf("cal_prev_year_%d_%d", year-1, month)},
{Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("cal_show_years_%d_%d", year, month)},
{Text: ">", CallbackData: fmt.Sprintf("cal_next_year_%d_%d", year+1, month)},
},
}
headerRow := []models.InlineKeyboardButton{}
for _, wn := range cm.WeekDays {
headerRow = append(headerRow, models.InlineKeyboardButton{Text: wn, CallbackData: "noop"})
}
kbRows = append(kbRows, headerRow)
row := []models.InlineKeyboardButton{}
for i, d := range cm.Days {
if d.DayNum == 0 {
row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"})
} else {
label := fmt.Sprintf("%d", d.DayNum)
if d.Date == todayKey {
label = fmt.Sprintf("[%d]", d.DayNum)
} else if hasEvent[d.Date] {
label = fmt.Sprintf("%d*", d.DayNum)
}
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: "cal_day_" + d.Date})
}
if len(row) == 7 || i == len(cm.Days)-1 {
for len(row) < 7 {
row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"})
}
kbRows = append(kbRows, row)
row = nil
}
}
prevY, prevM, nextY, nextM := domain.NavMonth(year, month)
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "<", CallbackData: fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)},
{Text: "Today", CallbackData: "history"},
{Text: ">", CallbackData: fmt.Sprintf("cal_next_%d_%d", nextY, nextM)},
})
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.sendOrEdit(ctx, chatID, msgID, text, &kb)
}
// -- Calendar callbacks routing --
func (h *Handler) routeHistoryCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
loc := h.loadLocation(user.Timezone)
if data == "history" {
h.editCalendar(ctx, chatID, msgID, 0, 0)
return
}
var y, m int
if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 {
h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y)
return
}
var startY, refY, refM int
if n, _ := fmt.Sscanf(data, "cal_years_%d_%d_%d", &startY, &refY, &refM); n == 3 {
h.editCalendarYearPicker(ctx, chatID, msgID, startY+6, refM, refY)
return
}
if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 {
h.editCalendar(ctx, chatID, msgID, y, 1)
return
}
if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
var date string
if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 {
h.editDayView(ctx, chatID, msgID, user, date, loc)
return
}
var eid int64
if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return
}
var wtid int64
if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 {
h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc)
return
}
if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 {
h.editEventType(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 {
h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 {
h.editEventTime(ctx, chatID, msgID, user, eid, loc)
return
}
var hh, mm int
if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 {
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc, cbID)
return
}
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc)
return
}
if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 {
h.promptNoteInput(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return
}
switch {
case strings.HasPrefix(data, "addin_"):
h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc)
case strings.HasPrefix(data, "addout_"):
h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc)
case strings.HasPrefix(data, "addtm_"):
h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc)
}
}
// -- Calendar year picker --
func (h *Handler) editCalendarYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refM, refY int) {
backData := fmt.Sprintf("cal_years_back_%d_%d", refY, refM)
navSuffix := fmt.Sprintf("_%d_%d", refY, refM)
kb := buildYearPicker("cal_", backData, navSuffix, centerYear)
h.editText(ctx, chatID, msgID, "Select year:", &kb)
}
// -- Day view --
func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, date string, loc *time.Location) {
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}
func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, date string, loc *time.Location, isNewMsg bool) {
events, err := h.Repo.EventsForDayByDate(user.ID, date)
if err != nil {
if isNewMsg {
h.sendText(ctx, chatID, "Error loading events")
}
return
}
text := fmt.Sprintf("Date: %s", domain.FormatDateForCalendar(date, user.Calendar))
if len(events) == 0 {
text += "\nNo events for this day."
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Add IN", CallbackData: "addin_" + date}},
{{Text: "Add OUT", CallbackData: "addout_" + date}},
{{Text: "Back to Calendar", CallbackData: "history"}},
},
}
h.sendOrEdit(ctx, chatID, msgID, text, &kb)
return
}
text += "\n\nEvents:"
kbRows := [][]models.InlineKeyboardButton{}
for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
label := "IN"
if e.EventType == "out" {
label = "OUT"
}
wt := ""
if e.WorkTypeID != nil {
if wtObj, err := h.Repo.GetWorkType(*e.WorkTypeID); err == nil {
wt = " [" + wtObj.Name + "]"
}
}
note := ""
if e.Note != "" {
note = " (" + e.Note + ")"
}
text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note)
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: fmt.Sprintf("Time %s", t), CallbackData: fmt.Sprintf("edit_time_%d", e.ID)},
{Text: "Type", CallbackData: fmt.Sprintf("edit_type_%d", e.ID)},
{Text: "Note", CallbackData: fmt.Sprintf("note_%d", e.ID)},
{Text: "DEL", CallbackData: fmt.Sprintf("delete_%d", e.ID), Style: "danger"},
})
}
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Add IN", CallbackData: "addin_" + date},
{Text: "Add OUT", CallbackData: "addout_" + date},
})
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back to Calendar", CallbackData: "history"},
})
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.sendOrEdit(ctx, chatID, msgID, text, &kb)
}
// -- Event editing --
func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
wts, err := h.Repo.GetWorkTypes()
if err != nil {
return
}
text := "Select new work type:"
kbRows := [][]models.InlineKeyboardButton{}
for _, wt := range wts {
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: wt.Name, CallbackData: fmt.Sprintf("settype_%d_%d", eventID, wt.ID)},
})
}
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: fmt.Sprintf("back_day_%d", eventID)},
})
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID, workTypeID int64, loc *time.Location) {
if err := h.Repo.UpdateEventWorkType(eventID, workTypeID); err != nil {
slog.Error("failed to update event work type", "event_id", eventID, "error", err)
h.sendDayView(ctx, chatID, msgID, user, "", loc, false)
return
}
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
h.sendDayView(ctx, chatID, msgID, user, t.Format(domain.DateLayout), loc, false)
}
func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
kb := buildHourPickerKeyboard(
func(hh int) string { return fmt.Sprintf("edittm_%d_%02d", eventID, hh) },
fmt.Sprintf("back_day_%d", eventID),
)
h.editText(ctx, chatID, msgID, "Select hour:", &kb)
}
func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, hh int, loc *time.Location) {
kb := buildMinutePickerKeyboard(
func(mm int) string { return fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm) },
fmt.Sprintf("edit_time_%d", eventID),
)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &kb)
}
func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, hh, mm int, loc *time.Location, cbID string) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc)
date := newTime.Format(domain.DateLayout)
events, err := h.Repo.EventsForDayByDate(user.ID, date)
if err == nil {
for _, e := range events {
if e.ID == eventID {
continue
}
eTime := time.Unix(e.OccurredAt, 0)
if newTime.Unix() == eTime.Unix() {
h.answerCb(ctx, cbID)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return
}
}
type eventPos struct {
id int64
typ string
t int64
}
sorted := []eventPos{}
for _, e := range events {
et := e.OccurredAt
if e.ID == eventID {
et = newTime.Unix()
}
sorted = append(sorted, eventPos{e.ID, e.EventType, et})
}
for i := 0; i < len(sorted); i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[j].t < sorted[i].t || (sorted[j].t == sorted[i].t && sorted[j].id < sorted[i].id) {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
for i := 1; i < len(sorted); i++ {
if sorted[i].typ == sorted[i-1].typ {
h.editText(ctx, chatID, msgID, "Edit rejected: overlapping events.", nil)
return
}
}
}
if err = h.Repo.UpdateEventTime(eventID, newTime.Unix()); err != nil {
slog.Error("failed to update event time", "event_id", eventID, "error", err)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return
}
h.recalcDayWorkSeconds(user.ID, date, loc)
h.recalcAggregates(user.ID, loc)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}
// -- Event deletion --
func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Yes, DEL", CallbackData: fmt.Sprintf("delconf_%d", eventID), Style: "danger"},
{Text: "Cancel", CallbackData: fmt.Sprintf("back_day_%d", eventID)},
},
},
}
h.editText(ctx, chatID, msgID, "Delete this event?", &kb)
}
func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
date := t.Format(domain.DateLayout)
if err = h.Repo.DeleteEvent(eventID); err != nil {
slog.Error("failed to delete event", "event_id", eventID, "error", err)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return
}
events, err := h.Repo.EventsForDayByDate(user.ID, date)
if err == nil && len(events) > 1 {
hasIssue := false
for i := 1; i < len(events); i++ {
if events[i].EventType == events[i-1].EventType {
hasIssue = true
break
}
}
if hasIssue {
h.sendText(ctx, chatID, "Warning: Timeline has consecutive events of same type after deletion.")
}
}
h.recalcDayWorkSeconds(user.ID, date, loc)
h.recalcAggregates(user.ID, loc)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}
func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
h.sendDayView(ctx, chatID, msgID, user, t.Format(domain.DateLayout), loc, false)
}
// -- Note via pending input --
func (h *Handler) promptNoteInput(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Cancel", CallbackData: fmt.Sprintf("note_cancel_%d", eventID)}},
},
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Send the note for event at %s:", t), &kb)
h.setPending(ctx, chatID, msgID, user.ID, PendingNote, map[string]string{"event_id": fmt.Sprintf("%d", eventID)})
}
// -- Adding events --
func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, loc *time.Location) {
kb := buildHourPickerKeyboard(
func(hh int) string { return fmt.Sprintf("addtm_%s_%s_%02d", eventType, date, hh) },
"cal_day_"+date,
)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Select hour for %s event:", eventType), &kb)
}
func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, payload string, loc *time.Location) {
parts := strings.SplitN(payload, "_", 4)
if len(parts) < 3 {
return
}
eventType := parts[0]
addDate := parts[1]
hh, err := strconv.Atoi(parts[2])
if err != nil {
return
}
if len(parts) == 3 {
h.addEventTimeMin(ctx, chatID, msgID, user, addDate, eventType, hh, loc)
return
}
mm, err := strconv.Atoi(parts[3])
if err != nil {
return
}
h.addEventDo(ctx, chatID, msgID, user, addDate, eventType, hh, mm, loc)
}
func (h *Handler) addEventTimeMin(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, hh int, loc *time.Location) {
kb := buildMinutePickerKeyboard(
func(mm int) string { return fmt.Sprintf("addtm_%s_%s_%02d_%02d", eventType, date, hh, mm) },
"add"+eventType+"_"+date,
)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &kb)
}
func (h *Handler) addEventDo(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, hh, mm int, loc *time.Location) {
t, err := time.ParseInLocation("2006-01-02 15:04", date+" "+fmt.Sprintf("%02d:%02d", hh, mm), loc)
if err != nil {
return
}
day, err := h.Repo.GetOrCreateDay(user.ID, date)
if err != nil {
return
}
var wt *int64
if eventType == "in" {
wt = &day.CurrentWorkTypeID
if *wt == 0 {
wt = nil
}
}
if err := h.Repo.CreateEvent(user.ID, day.ID, eventType, wt, t.Unix(), ""); err != nil {
return
}
h.recalcDayWorkSeconds(user.ID, date, loc)
h.recalcAggregates(user.ID, loc)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}

222
internal/handler/clock.go Normal file
View File

@@ -0,0 +1,222 @@
package handler
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Commands --
func (h *Handler) handleClockIn(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doClockIn(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleClockOut(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doClockOut(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleDayOff(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doDayOff(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleReport(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doReport(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleReportToggle(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
st.ReportEnabled = !st.ReportEnabled
if err := h.Repo.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving settings")
return
}
status := "disabled"
if st.ReportEnabled {
status = "enabled"
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Daily report %s", status))
}
// -- Callbacks --
func (h *Handler) clockInCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doClockIn(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) clockOutCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doClockOut(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) dayOffCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doDayOff(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) reportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doReport(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Business logic (shared between commands and callbacks) --
func (h *Handler) doClockIn(user *domain.User) (string, error) {
day, err := h.getTodayDay(user)
if err != nil {
return "", err
}
last, err := h.Repo.GetLastEvent(user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
if last != nil && last.EventType == "in" {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
lastTime := time.Unix(last.OccurredAt, 0).In(loc)
if lastTime.Format(domain.DateLayout) == now.Format(domain.DateLayout) {
return "", errors.New("already clocked in")
}
}
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
wt := day.CurrentWorkTypeID
if err := h.Repo.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
return "", err
}
return fmt.Sprintf("Clocked in at %s", now.Format(domain.TimeLayout)), nil
}
func (h *Handler) doClockOut(user *domain.User) (string, error) {
day, err := h.getTodayDay(user)
if err != nil {
return "", err
}
last, err := h.Repo.GetLastEvent(user.ID)
if err != nil {
return "", err
}
if last == nil {
return "", errors.New("no clock-in found - start with /clockin first")
}
if last.EventType == "out" {
return "", errors.New("already clocked out")
}
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
if err := h.Repo.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
return "", err
}
text := fmt.Sprintf("Clocked out at %s", now.Format(domain.TimeLayout))
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err == nil && st.RPGEnabled {
sessionWork := now.Unix() - last.OccurredAt
today := now.Format(domain.DateLayout)
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
stats, err := h.Repo.GetOrCreateRPGStats(user.ID)
if err == nil {
text += h.awardXPAndCheckAchievements(user, stats, sessionWork, today, isWeekend)
}
}
return text, nil
}
func (h *Handler) doDayOff(user *domain.User) (string, error) {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return "", err
}
if day.IsDayOff {
if err := h.Repo.RemoveDayOff(user.ID, day.Date); err != nil {
return "", err
}
return "Day off removed", nil
}
if err := h.Repo.SetDayOff(user.ID, day.Date, ""); err != nil {
return "", err
}
return "Today marked as day off", nil
}
func (h *Handler) doReport(user *domain.User) (string, error) {
day, err := h.getTodayDay(user)
if err != nil {
return "", err
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil {
return "", err
}
return h.buildDailyReport(user, day, events), nil
}
func (h *Handler) getTodayDay(user *domain.User) (*domain.Day, error) {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
return h.Repo.GetOrCreateDay(user.ID, today)
}
func (h *Handler) getTodayBreakThreshold(user *domain.User) int64 {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetDay(user.ID, today)
if err != nil {
return domain.DefaultBreakThreshold
}
return day.MinBreakThreshold
}

531
internal/handler/export.go Normal file
View File

@@ -0,0 +1,531 @@
package handler
import (
"bytes"
"context"
"fmt"
"log/slog"
"strings"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/xuri/excelize/v2"
"worktimeBot/internal/domain"
)
type themeColors struct {
headerFill string
border string
totalFill string
}
var themes = map[string]themeColors{
"ocean": {headerFill: "4472C4", border: "D9D9D9", totalFill: "D9E2F3"},
"beach": {headerFill: "C87D3C", border: "E8D5B7", totalFill: "FFF0E0"},
"rose": {headerFill: "B86C80", border: "F0D0E0", totalFill: "FCE8F0"},
"catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"},
}
type reportStyles struct {
title int
header int
data int
total int
dayOff int
}
func newReportStyles(f *excelize.File, theme themeColors) reportStyles {
return reportStyles{
title: newStyle(f, &excelize.Style{
Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}),
header: newStyle(f, &excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
Border: []excelize.Border{
{Type: "left", Color: "FFFFFF", Style: 1},
{Type: "right", Color: "FFFFFF", Style: 1},
{Type: "top", Color: "FFFFFF", Style: 1},
{Type: "bottom", Color: "FFFFFF", Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}),
data: newStyle(f, &excelize.Style{
Border: []excelize.Border{
{Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: theme.border, Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}),
total: newStyle(f, &excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
Border: []excelize.Border{
{Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: theme.border, Style: 2},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}),
dayOff: newStyle(f, &excelize.Style{
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
Border: []excelize.Border{
{Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: theme.border, Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}),
}
}
func newStyle(f *excelize.File, s *excelize.Style) int {
id, _ := f.NewStyle(s)
return id
}
func (h *Handler) generateMonthlyReport(user *domain.User, calYear, calMonth int, start, end time.Time) ([]byte, error) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return nil, err
}
accent := st.ExportAccent
theme, ok := themes[accent]
if !ok {
theme = themes["ocean"]
}
f := excelize.NewFile()
defer f.Close()
sheet := "Report"
index, err := f.NewSheet(sheet)
if err != nil {
return nil, err
}
f.SetActiveSheet(index)
f.DeleteSheet("Sheet1")
styles := newReportStyles(f, theme)
monthName := domain.FormatMonthTitle(user.Calendar, calYear, calMonth)
h.writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles)
loc := h.loadLocation(user.Timezone)
row := 3
userDays, err := h.Repo.GetUserDaysInRange(user.ID, start.Format(domain.DateLayout), end.Format(domain.DateLayout))
if err != nil {
return nil, err
}
dayMap := make(map[string]*domain.Day)
for i := range userDays {
dayMap[userDays[i].Date] = &userDays[i]
}
var totalWork, totalBreak int64
layout := start.Format(domain.DateLayout)
endLayout := end.Format(domain.DateLayout)
cur := start
for !cur.After(end) {
dateStr := cur.Format(domain.DateLayout)
displayDate := domain.FormatDateForCalendar(dateStr, user.Calendar)
var (
day *domain.Day
events []domain.Event
hasDayOff bool
)
if existing, ok := dayMap[dateStr]; ok {
day = existing
events, err = h.Repo.EventsForDayByDayID(day.ID)
if err != nil {
return nil, err
}
hasDayOff = day.IsDayOff
}
var typeLabel string
if hasDayOff {
if day.DayOffReason != "" {
typeLabel = "Day Off (" + day.DayOffReason + ")"
} else {
typeLabel = "Day Off"
}
} else if len(events) > 0 {
typeLabel = h.workTypeLabel(day, events)
}
ws, bs := h.writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles)
totalWork += ws
totalBreak += bs
row++
cur = cur.AddDate(0, 0, 1)
}
_ = layout
_ = endLayout
h.writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles)
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (h *Handler) writeReportHeader(f *excelize.File, sheet, title string, s reportStyles) {
f.SetCellValue(sheet, "A1", title)
f.MergeCell(sheet, "A1", "F1")
f.SetCellStyle(sheet, "A1", "F1", s.title)
f.SetRowHeight(sheet, 1, 30)
headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"}
for i, hdr := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
f.SetCellValue(sheet, cell, hdr)
f.SetCellStyle(sheet, cell, cell, s.header)
}
f.SetRowHeight(sheet, 2, 22)
f.SetColWidth(sheet, "A", "A", 14)
f.SetColWidth(sheet, "B", "B", 10)
f.SetColWidth(sheet, "C", "C", 10)
f.SetColWidth(sheet, "D", "D", 12)
f.SetColWidth(sheet, "E", "E", 10)
f.SetColWidth(sheet, "F", "F", 10)
}
func (h *Handler) writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typeLabel string, events []domain.Event, day *domain.Day, loc *time.Location, hasDayOff bool, s reportStyles) (workSec, breakSec int64) {
cellDate, _ := excelize.CoordinatesToCellName(1, row)
f.SetCellValue(sheet, cellDate, displayDate)
f.SetCellStyle(sheet, cellDate, cellDate, s.data)
if len(events) > 0 {
cellIn, _ := excelize.CoordinatesToCellName(2, row)
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format(domain.TimeLayout))
f.SetCellStyle(sheet, cellIn, cellIn, s.data)
}
if len(events) > 1 {
lastEvent := events[len(events)-1]
if lastEvent.EventType == "out" {
cellOut, _ := excelize.CoordinatesToCellName(3, row)
f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format(domain.TimeLayout))
f.SetCellStyle(sheet, cellOut, cellOut, s.data)
}
}
cellType, _ := excelize.CoordinatesToCellName(4, row)
f.SetCellValue(sheet, cellType, typeLabel)
f.SetCellStyle(sheet, cellType, cellType, s.data)
if len(events) > 0 && day != nil {
d, err := time.Parse(domain.DateLayout, day.Date)
if err == nil {
dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
workSec = totals.TotalSeconds
breakSec = totals.BreakSeconds
}
}
cellWork, _ := excelize.CoordinatesToCellName(5, row)
f.SetCellValue(sheet, cellWork, fmtHHMM(workSec))
f.SetCellStyle(sheet, cellWork, cellWork, s.data)
cellBreak, _ := excelize.CoordinatesToCellName(6, row)
f.SetCellValue(sheet, cellBreak, fmtHHMM(breakSec))
f.SetCellStyle(sheet, cellBreak, cellBreak, s.data)
if hasDayOff {
cellA, _ := excelize.CoordinatesToCellName(1, row)
cellF, _ := excelize.CoordinatesToCellName(6, row)
f.SetCellStyle(sheet, cellA, cellF, s.dayOff)
}
return workSec, breakSec
}
func (h *Handler) writeReportTotalRow(f *excelize.File, sheet string, row int, totalWork, totalBreak int64, s reportStyles) {
row++
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), s.total)
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork))
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), s.total)
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak))
f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), s.total)
}
func fmtHHMM(seconds int64) string {
hours := seconds / domain.SecondsPerHour
mins := (seconds % domain.SecondsPerHour) / 60
return fmt.Sprintf("%d:%02d", hours, mins)
}
func fmtDDHHMM(seconds int64) string {
days := seconds / domain.SecondsPerDay
rem := seconds % domain.SecondsPerDay
hours := rem / domain.SecondsPerHour
mins := (rem % domain.SecondsPerHour) / 60
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
}
func (h *Handler) workTypeLabel(day *domain.Day, events []domain.Event) string {
wtSeen := make(map[int64]bool)
for _, e := range events {
if e.WorkTypeID != nil {
wtSeen[*e.WorkTypeID] = true
}
}
if len(wtSeen) == 0 {
wt, err := h.Repo.GetWorkType(day.CurrentWorkTypeID)
if err == nil {
return wt.Name
}
return ""
}
if len(wtSeen) == 1 {
for id := range wtSeen {
wt, err := h.Repo.GetWorkType(id)
if err == nil {
return wt.Name
}
}
}
return "mixed"
}
// -- Commands --
func (h *Handler) handleExport(ctx context.Context, msg *models.Message, user *domain.User) {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
calY, calM := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
}
args := strings.Fields(msg.Text)
if len(args) >= 1 && args[0][0] == '/' {
args = args[1:]
}
if len(args) >= 1 {
if n, _ := fmt.Sscanf(args[0], "%d-%d", &calY, &calM); n == 2 {
if calY < 0 || calM < 1 || calM > 12 {
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
return
}
}
}
h.sendExportDirect(ctx, msg.Chat.ID, user, calY, calM)
}
func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *domain.User, calYear, calMonth int) {
last, err := h.Repo.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
h.sendText(ctx, chatID, "You are currently clocked in. Please clock out first, then try again.")
return
}
loc := h.loadLocation(user.Timezone)
startStr, endStr := domain.MonthGregorianRange(user.Calendar, calYear, calMonth)
start, err := time.Parse(domain.DateLayout, startStr)
if err != nil {
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
}
end, err := time.Parse(domain.DateLayout, endStr)
if err != nil {
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
end = startOfMonth.AddDate(0, 1, -1)
}
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
data, err := h.generateMonthlyReport(user, calYear, calMonth, start, end)
if err != nil {
slog.Error("generate report", "error", err)
h.sendText(ctx, chatID, "Error generating report")
return
}
slog.Info("report generated", "bytes", len(data))
if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
Data: bytes.NewReader(data),
},
}); err != nil {
slog.Error("send document", "error", err)
}
}
// -- Callbacks --
func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
cy, cm := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
cy, cm, _ = domain.GregorianToJalali(cy, cm, now.Day())
case "hijri":
cy, cm, _ = domain.GregorianToHijri(cy, cm, now.Day())
}
h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user)
}
func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *domain.User) {
months := domain.GregMonthNames
switch user.Calendar {
case "jalali":
months = domain.JalaliMonthNames
case "hijri":
months = domain.HijriMonthNames
}
kbRows := [][]models.InlineKeyboardButton{
{
{Text: "<", CallbackData: fmt.Sprintf("exp_year_prev_%d", year)},
{Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("exp_show_years_%d", year)},
{Text: ">", CallbackData: fmt.Sprintf("exp_year_next_%d", year)},
},
}
for i := 0; i < 12; i += 4 {
row := []models.InlineKeyboardButton{}
for j := i; j < i+4 && j < 12; j++ {
row = append(row, models.InlineKeyboardButton{
Text: months[j], CallbackData: fmt.Sprintf("exp_do_%d_%d", year, j+1),
})
}
kbRows = append(kbRows, row)
}
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.sendOrEdit(ctx, chatID, msgID, "Select month to export:", &kb)
}
func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
defer h.answerCb(ctx, cbID)
if h.handleExportNav(ctx, chatID, msgID, user, data) {
return
}
h.handleExportDo(ctx, chatID, msgID, user, data)
}
func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *domain.User, data string) bool {
var y int
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user)
return true
}
if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y+1, 0, user)
return true
}
if n, _ := fmt.Sscanf(data, "exp_show_years_%d", &y); n == 1 {
h.editExportYearPicker(ctx, chatID, msgID, y, y)
return true
}
var refY int
if n, _ := fmt.Sscanf(data, "exp_years_%d_%d", &y, &refY); n == 2 {
h.editExportYearPicker(ctx, chatID, msgID, y+6, refY)
return true
}
if n, _ := fmt.Sscanf(data, "exp_year_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user)
return true
}
if n, _ := fmt.Sscanf(data, "exp_years_back_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user)
return true
}
return false
}
func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *domain.User, data string) {
var y, m int
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 {
return
}
last, err := h.Repo.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
backKB := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, "You are currently clocked in. Please clock out first, then try again.", &backKB)
return
}
startStr, endStr := domain.MonthGregorianRange(user.Calendar, y, m)
start, err := time.Parse(domain.DateLayout, startStr)
if err != nil {
h.sendText(ctx, chatID, "Error processing date")
return
}
end, err := time.Parse(domain.DateLayout, endStr)
if err != nil {
h.sendText(ctx, chatID, "Error processing date")
return
}
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
reportData, err := h.generateMonthlyReport(user, y, m, start, end)
if err != nil {
slog.Error("generate report", "error", err)
backKB := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, "Error generating report", &backKB)
return
}
slog.Info("report generated", "bytes", len(reportData))
backKB := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, "Report ready:", &backKB)
if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
Data: bytes.NewReader(reportData),
},
}); err != nil {
slog.Error("send document", "error", err)
}
}
func (h *Handler) editExportYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refY int) {
backData := fmt.Sprintf("exp_years_back_%d", refY)
navSuffix := fmt.Sprintf("_%d", refY)
kb := buildYearPicker("exp_", backData, navSuffix, centerYear)
h.editText(ctx, chatID, msgID, "Select year:", &kb)
}

464
internal/handler/handler.go Normal file
View File

@@ -0,0 +1,464 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
"worktimeBot/internal/repo"
)
const rateLimitInterval = 1 * time.Second
type PendingKind string
const (
PendingNote PendingKind = "note"
PendingRate PendingKind = "rate"
PendingCurrency PendingKind = "currency"
PendingTimezone PendingKind = "timezone"
PendingCalendar PendingKind = "calendar"
PendingBreak PendingKind = "break_threshold"
PendingUsername PendingKind = "username"
PendingReportTime PendingKind = "report_time"
)
type PendingInput struct {
Kind PendingKind
UserID int64
CreatedAt time.Time
MsgID int
Data map[string]string
}
type Handler struct {
Bot *tgbot.Bot
Repo repo.Repository
AllowedUsers map[int64]bool
lastMsgTime map[int64]time.Time
pendingInput map[int64]*PendingInput
mu sync.Mutex
}
func NewHandler(bot *tgbot.Bot, store repo.Repository, allowed map[int64]bool) *Handler {
h := &Handler{
Bot: bot,
Repo: store,
AllowedUsers: allowed,
lastMsgTime: make(map[int64]time.Time),
pendingInput: make(map[int64]*PendingInput),
}
go h.periodicCleanup()
return h
}
func (h *Handler) periodicCleanup() {
for {
time.Sleep(10 * time.Second)
h.mu.Lock()
cutoff := time.Now().Add(-rateLimitInterval * 2)
for uid, t := range h.lastMsgTime {
if t.Before(cutoff) {
delete(h.lastMsgTime, uid)
}
}
pendingCutoff := time.Now().Add(-5 * time.Minute)
for chatID, pi := range h.pendingInput {
if pi.CreatedAt.Before(pendingCutoff) {
delete(h.pendingInput, chatID)
}
}
h.mu.Unlock()
}
}
func (h *Handler) isRateLimited(userID int64) bool {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < rateLimitInterval {
return true
}
h.lastMsgTime[userID] = now
return false
}
func (h *Handler) getOrCreateUser(chatID int64) (*domain.User, error) {
return h.Repo.GetOrCreateUser(chatID)
}
func (h *Handler) loadLocation(tz string) *time.Location {
loc, err := time.LoadLocation(tz)
if err != nil {
return time.UTC
}
return loc
}
func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] {
return
}
if msg.Text == "" {
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
return
}
if h.isRateLimited(user.ID) {
return
}
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
if msg.Text[0] == '/' {
h.mu.Lock()
delete(h.pendingInput, msg.Chat.ID)
h.mu.Unlock()
h.routeCommand(ctx, msg, user)
return
}
h.mu.Lock()
pi, hasPI := h.pendingInput[msg.Chat.ID]
if hasPI {
delete(h.pendingInput, msg.Chat.ID)
}
h.mu.Unlock()
if hasPI {
h.processPendingInput(ctx, msg, user, pi)
return
}
h.sendText(ctx, msg.Chat.ID, "Unknown command")
}
func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, user *domain.User) {
cmd := msg.Text
if i := strings.IndexByte(msg.Text, ' '); i >= 0 {
cmd = msg.Text[:i]
}
switch cmd {
case "/start":
h.handleStart(ctx, msg, user)
case "/settings":
h.handleSettings(ctx, msg, user)
case "/help":
h.handleHelp(ctx, msg)
case "/clockin":
h.handleClockIn(ctx, msg, user)
case "/clockout":
h.handleClockOut(ctx, msg, user)
case "/worktype":
h.handleWorkType(ctx, msg, user)
case "/report":
h.handleReport(ctx, msg, user)
case "/export":
h.handleExport(ctx, msg, user)
case "/dayoff":
h.handleDayOff(ctx, msg, user)
case "/history":
h.handleHistory(ctx, msg, user)
case "/edit":
h.handleEdit(ctx, msg, user)
case "/reporttoggle":
h.handleReportToggle(ctx, msg, user)
case "/setreporttime":
h.handleSetReportTime(ctx, msg, user)
case "/setaccent":
h.handleSetAccent(ctx, msg, user)
case "/settimezone":
h.handleSetTimezone(ctx, msg, user)
case "/setcalendar":
h.handleSetCalendar(ctx, msg, user)
case "/note":
h.handleNote(ctx, msg, user)
case "/summary":
h.handleSummary(ctx, msg, user)
case "/setbreak":
h.handleSetBreak(ctx, msg, user)
case "/rpg":
h.handleRPG(ctx, msg, user)
case "/achievements":
h.handleAchievements(ctx, msg, user)
case "/salary":
h.handleSalary(ctx, msg, user)
case "/burnout":
h.handleBurnout(ctx, msg, user)
case "/league":
h.handleLeague(ctx, msg, user)
case "/setrate":
h.handleSetRate(ctx, msg, user)
case "/setcurrency":
h.handleSetCurrency(ctx, msg, user)
case "/rpgtoggle":
h.handleRPGSettings(ctx, msg, user)
case "/leaguetoggle":
h.handleLeagueToggle(ctx, msg, user)
case "/setusername":
h.handleSetUsername(ctx, msg, user)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown command")
}
}
func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) {
chatID := cb.Message.Message.Chat.ID
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[chatID] {
h.answerCbWithText(ctx, cb.ID, "Unauthorized")
return
}
user, err := h.getOrCreateUser(chatID)
if err != nil {
h.answerCb(ctx, cb.ID)
return
}
if h.isRateLimited(user.ID) {
h.answerCb(ctx, cb.ID)
return
}
slog.Info("callback", "chat_id", chatID, "data", cb.Data)
h.mu.Lock()
delete(h.pendingInput, chatID)
h.mu.Unlock()
msgID := cb.Message.Message.ID
data := cb.Data
switch {
case data == "pending_cancel":
h.backToMenu(ctx, chatID, msgID, cb.ID)
case data == "clockin":
h.clockInCallback(ctx, chatID, msgID, cb.ID, user)
case data == "clockout":
h.clockOutCallback(ctx, chatID, msgID, cb.ID, user)
case data == "dayoff":
h.dayOffCallback(ctx, chatID, msgID, cb.ID, user)
case data == "report":
h.reportCallback(ctx, chatID, msgID, cb.ID, user)
case data == "worktype":
h.workTypeCallback(ctx, chatID, msgID, cb.ID, user)
case data == "export":
h.exportCallback(ctx, chatID, msgID, cb.ID, user)
case data == "settings":
h.settingsCallback(ctx, chatID, msgID, cb.ID, user)
case data == "history":
h.historyCallback(ctx, chatID, msgID, cb.ID, user)
case data == "noop":
h.answerCb(ctx, cb.ID)
case data == "rpg":
h.rpgCallback(ctx, chatID, msgID, cb.ID, user)
case data == "achievements":
h.achievementsCallback(ctx, chatID, msgID, cb.ID, user)
case data == "salary":
h.salaryCallback(ctx, chatID, msgID, cb.ID, user)
case data == "burnout":
h.burnoutCallback(ctx, chatID, msgID, cb.ID, user)
case data == "league":
h.leagueCallback(ctx, chatID, msgID, cb.ID, user, string(domain.LeagueSortXP))
case data == "back_menu":
h.backToMenu(ctx, chatID, msgID, cb.ID)
default:
h.routePrefixedCallback(ctx, chatID, msgID, cb.ID, user, data)
}
}
func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
switch {
case strings.HasPrefix(data, "worktype_"):
h.selectWorkType(ctx, chatID, msgID, cbID, user, data[9:])
case strings.HasPrefix(data, "accent_"):
h.selectAccent(ctx, chatID, msgID, cbID, user, data[7:])
case strings.HasPrefix(data, "caltype_"):
h.selectCalendar(ctx, chatID, msgID, cbID, user, data[8:])
case strings.HasPrefix(data, "exp_"):
h.handleExportCallback(ctx, chatID, msgID, cbID, user, data)
case strings.HasPrefix(data, "breakthreshold_"):
h.selectBreakThreshold(ctx, chatID, msgID, cbID, user, data[15:])
case strings.HasPrefix(data, "currency_"):
h.selectCurrency(ctx, chatID, msgID, cbID, user, data[9:])
case data == "rpg_enable":
h.setRPGCallback(ctx, chatID, msgID, cbID, user, true)
case data == "rpg_disable":
h.setRPGCallback(ctx, chatID, msgID, cbID, user, false)
case data == "league_enable":
h.setLeagueCallback(ctx, chatID, msgID, cbID, user, true)
case data == "league_disable":
h.setLeagueCallback(ctx, chatID, msgID, cbID, user, false)
case data == "report_enable":
h.setReportToggleCallback(ctx, chatID, msgID, cbID, user, true)
case data == "report_disable":
h.setReportToggleCallback(ctx, chatID, msgID, cbID, user, false)
case data == "back_settings":
h.backToSettings(ctx, chatID, msgID, cbID, user)
case data == "rpgtoggle":
h.rpgToggleCallback(ctx, chatID, msgID, cbID, user)
case data == "leaguetoggle":
h.leagueToggleCallback(ctx, chatID, msgID, cbID, user)
case data == "reporttoggle":
h.reportToggleCallback(ctx, chatID, msgID, cbID, user)
case data == "rpg_achievements":
h.rpgAchievementsCallback(ctx, chatID, msgID, cbID, user)
case data == "rpg_back":
h.rpgBackCallback(ctx, chatID, msgID, cbID, user)
case data == "username":
h.usernameCallback(ctx, chatID, msgID, cbID, user)
case data == "currency":
h.currencyCallback(ctx, chatID, msgID, cbID, user)
case data == "setrate":
h.rateCallback(ctx, chatID, msgID, cbID, user)
case data == "timezone":
h.timezoneCallback(ctx, chatID, msgID, cbID, user)
case data == "caltype":
h.calTypeCallback(ctx, chatID, msgID, cbID, user)
case data == "accent":
h.accentCallback(ctx, chatID, msgID, cbID, user)
case data == "reporttime":
h.reportTimeCallback(ctx, chatID, msgID, cbID, user)
case data == "breakthreshold":
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_"):
sortBy := data[7:]
h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy)
default:
h.routeHistoryCallback(ctx, chatID, msgID, cbID, user, data)
}
}
func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, data map[string]string) {
h.mu.Lock()
h.pendingInput[chatID] = &PendingInput{
Kind: kind,
UserID: userID,
CreatedAt: time.Now(),
MsgID: msgID,
Data: data,
}
h.mu.Unlock()
}
func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput) {
text := strings.TrimSpace(msg.Text)
switch pi.Kind {
case PendingNote:
h.processNoteInput(ctx, msg, user, pi, text)
case PendingRate:
h.processRateInput(ctx, msg, user, pi, text)
case PendingCurrency:
h.processCurrencyInput(ctx, msg, user, pi, text)
case PendingTimezone:
h.processTimezoneInput(ctx, msg, user, pi, text)
case PendingCalendar:
h.processCalendarInput(ctx, msg, user, pi, text)
case PendingBreak:
h.processBreakInput(ctx, msg, user, pi, text)
case PendingUsername:
h.processUsernameInput(ctx, msg, user, pi, text)
case PendingReportTime:
h.processReportTimeInput(ctx, msg, user, pi, text)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown input type")
}
}
func (h *Handler) promptForInput(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, prompt string, data map[string]string) {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Cancel", CallbackData: "pending_cancel"}},
},
}
if msgID == 0 {
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: chatID, Text: prompt, ReplyMarkup: kb,
})
if err != nil {
slog.Warn("send prompt failed", "chat_id", chatID, "error", err)
return
}
h.setPending(ctx, chatID, msg.ID, userID, kind, data)
} else {
h.editText(ctx, chatID, msgID, prompt, &kb)
h.setPending(ctx, chatID, msgID, userID, kind, data)
}
}
// sendOrEdit sends a new message or edits an existing one depending on msgID.
func (h *Handler) sendOrEdit(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
if msgID == 0 {
if kb != nil {
h.sendWithKB(ctx, chatID, text, *kb)
} else {
h.sendText(ctx, chatID, text)
}
} else {
h.editText(ctx, chatID, msgID, text, kb)
}
}
// -- helpers --
func (h *Handler) sendText(ctx context.Context, chatID int64, text string) {
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil {
slog.Warn("send text failed", "chat_id", chatID, "error", err)
}
}
func (h *Handler) editText(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
p := &tgbot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text}
if kb != nil {
p.ReplyMarkup = kb
}
if _, err := h.Bot.EditMessageText(ctx, p); err != nil {
slog.Warn("edit text failed", "chat_id", chatID, "msg_id", msgID, "error", err)
}
}
func (h *Handler) sendWithKB(ctx context.Context, chatID int64, text string, kb models.InlineKeyboardMarkup) {
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb}); err != nil {
slog.Warn("send with keyboard failed", "chat_id", chatID, "error", err)
}
}
func (h *Handler) answerCb(ctx context.Context, callbackID string) {
if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil {
slog.Debug("answer callback failed", "error", err)
}
}
func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string) {
if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID, Text: text}); err != nil {
slog.Debug("answer callback with text failed", "error", err)
}
}
func (h *Handler) tryUnlockAchievement(userID int64, code string) {
a, err := h.Repo.GetAchievementByCode(code)
if err != nil {
return
}
if _, err := h.Repo.UnlockAchievement(userID, a.ID); err != nil {
slog.Error("unlock achievement", "code", code, "error", err)
}
}
func formatDuration(seconds int64) string {
hours := seconds / domain.SecondsPerHour
mins := (seconds % domain.SecondsPerHour) / 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}

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

@@ -0,0 +1,111 @@
package handler
import (
"fmt"
"github.com/go-telegram/bot/models"
)
func mainKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Clock In", CallbackData: "clockin"},
{Text: "Clock Out", CallbackData: "clockout"},
},
{
{Text: "Work Type", CallbackData: "worktype"},
{Text: "Day Off", CallbackData: "dayoff"},
},
{
{Text: "Report", CallbackData: "report"},
{Text: "Export", CallbackData: "export"},
{Text: "History", CallbackData: "history"},
},
{
{Text: "RPG", CallbackData: "rpg"},
{Text: "League", CallbackData: "league"},
{Text: "Burnout", CallbackData: "burnout"},
},
{
{Text: "Salary", CallbackData: "salary"},
{Text: "Settings", CallbackData: "settings"},
},
},
}
}
func backKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
}
func settingsBackKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
}
// buildYearPicker returns a 12-year grid keyboard centered on centerYear.
func buildYearPicker(cbPrefix, backData, navSuffix string, centerYear int) models.InlineKeyboardMarkup {
blockStart := centerYear - 6
kbRows := [][]models.InlineKeyboardButton{
{
{Text: "<", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart-12, navSuffix)},
{Text: fmt.Sprintf("%d", centerYear), CallbackData: "noop"},
{Text: ">", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart+12, navSuffix)},
},
}
for i := 0; i < 12; i += 4 {
row := []models.InlineKeyboardButton{}
for j := i; j < i+4; j++ {
y := blockStart + j
row = append(row, models.InlineKeyboardButton{
Text: fmt.Sprintf("%d", y), CallbackData: fmt.Sprintf("%syear_%d", cbPrefix, y),
})
}
kbRows = append(kbRows, row)
}
if backData != "" {
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: backData},
})
}
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}
// buildHourPickerKeyboard creates the 24-hour grid for time selection.
func buildHourPickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup {
kbRows := [][]models.InlineKeyboardButton{}
hourRow := []models.InlineKeyboardButton{}
for hh := 0; hh < 24; hh++ {
hourRow = append(hourRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("%02d", hh), CallbackData: cb(hh),
})
if len(hourRow) == 6 || hh == 23 {
kbRows = append(kbRows, hourRow)
hourRow = nil
}
}
kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}})
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}
// buildMinutePickerKeyboard creates the 15-min interval row for minute selection.
func buildMinutePickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup {
kbRows := [][]models.InlineKeyboardButton{}
minRow := []models.InlineKeyboardButton{}
for _, mm := range []int{0, 10, 20, 30, 40, 50} {
minRow = append(minRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("%02d", mm), CallbackData: cb(mm),
})
}
kbRows = append(kbRows, minRow)
kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}})
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}

111
internal/handler/league.go Normal file
View File

@@ -0,0 +1,111 @@
package handler
import (
"context"
"fmt"
"strings"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Command --
func (h *Handler) handleLeague(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.LeagueOptIn {
h.sendText(ctx, msg.Chat.ID, "League participation is disabled.\nEnable it in Settings or use /leaguetoggle.")
return
}
text := h.buildLeagueText(domain.LeagueSortXP)
kb := h.buildLeagueKeyboard(string(domain.LeagueSortXP))
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// -- Callback --
func (h *Handler) leagueCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, orderBy string) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if !st.LeagueOptIn {
kb := backKeyboard()
h.editText(ctx, chatID, msgID, "League participation is disabled.\nEnable it in Settings to view rankings.", &kb)
return
}
text := h.buildLeagueText(domain.LeagueSortOption(orderBy))
kb := h.buildLeagueKeyboard(orderBy)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Builders --
func (h *Handler) buildLeagueText(orderBy domain.LeagueSortOption) string {
entries, err := h.Repo.GetLeagueRankings(string(orderBy))
if err != nil {
return "League rankings unavailable."
}
if len(entries) == 0 {
return "No participants in the league yet.\nEnable League participation in Settings to join!"
}
var b strings.Builder
orderLabel := map[string]string{"xp": "XP", "level": "Level", "streak": "Streak", "hours": "Hours"}
b.WriteString(fmt.Sprintf("WorkTime League - %s\n\n", orderLabel[string(orderBy)]))
for i, e := range entries {
rank := i + 1
name := e.Username
if name == "" {
name = "Anonymous"
}
b.WriteString(fmt.Sprintf("%d %s\n", rank, name))
b.WriteString(fmt.Sprintf(" XP:%d Lv:%d S:%d %.1fh\n", e.XP, e.Level, e.Streak, e.TotalHours))
}
b.WriteString(fmt.Sprintf("\n%d participant(s)\n", len(entries)))
return b.String()
}
func (h *Handler) buildLeagueRankText(userID int64) string {
entries, err := h.Repo.GetLeagueRankings("xp")
if err != nil || len(entries) == 0 {
return ""
}
for i, e := range entries {
if e.UserID == userID {
return fmt.Sprintf("League rank: #%d of %d", i+1, len(entries))
}
}
return ""
}
func (h *Handler) buildLeagueKeyboard(currentOrder string) models.InlineKeyboardMarkup {
type sortBtn struct{ label, data string }
orders := []sortBtn{
{"XP", "league_xp"},
{"Level", "league_level"},
{"Streak", "league_streak"},
{"Hours", "league_hours"},
}
rows := [][]models.InlineKeyboardButton{}
for i := 0; i < len(orders); i += 2 {
row := []models.InlineKeyboardButton{}
for j := i; j < i+2 && j < len(orders); j++ {
label := orders[j].label
if orders[j].data == "league_"+currentOrder {
label = "> " + label
}
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: orders[j].data})
}
rows = append(rows, row)
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
}

91
internal/handler/note.go Normal file
View File

@@ -0,0 +1,91 @@
package handler
import (
"context"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// handleNote handles /note [YYYY-MM-DD] HH:MM <text> command.
func (h *Handler) handleNote(ctx context.Context, msg *models.Message, user *domain.User) {
loc := h.loadLocation(user.Timezone)
parts := strings.Fields(msg.Text)
if len(parts) < 3 {
h.promptForNote(ctx, msg, user)
return
}
var dateStr string
var timeStr string
var noteParts []string
if len(parts[1]) == 10 && parts[1][4] == '-' && parts[1][7] == '-' {
dateStr = parts[1]
timeStr = parts[2]
noteParts = parts[3:]
} else {
dateStr = time.Now().In(loc).Format(domain.DateLayout)
timeStr = parts[1]
noteParts = parts[2:]
}
if len(noteParts) == 0 {
h.promptForNote(ctx, msg, user)
return
}
events, err := h.Repo.EventsForDayByDate(user.ID, dateStr)
if err != nil || len(events) == 0 {
h.sendText(ctx, msg.Chat.ID, "No events found for that day")
return
}
var targetEvent *domain.Event
for i := range events {
t := time.Unix(events[i].OccurredAt, 0).In(loc).Format(domain.TimeLayout)
if t == timeStr {
targetEvent = &events[i]
break
}
}
if targetEvent == nil {
h.sendText(ctx, msg.Chat.ID, "No event found at that time")
return
}
note := domain.SanitizeNote(strings.Join(noteParts, " "))
if err := h.Repo.SetEventNote(targetEvent.ID, note); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving note")
return
}
h.sendText(ctx, msg.Chat.ID, "Note saved")
}
func (h *Handler) promptForNote(ctx context.Context, msg *models.Message, user *domain.User) {
h.sendText(ctx, msg.Chat.ID, "Usage: /note [YYYY-MM-DD] HH:MM <text>\nUse the History menu to add notes visually.")
}
// handleEdit handles /edit YYYY-MM-DD command.
func (h *Handler) handleEdit(ctx context.Context, msg *models.Message, user *domain.User) {
date := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/edit"))
if date == "" {
h.sendText(ctx, msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)")
return
}
if len(date) != 10 || date[4] != '-' || date[7] != '-' {
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)")
return
}
_, m, d := domain.ParseGregorianDateKey(date)
if m < 1 || m > 12 || d < 1 || d > 31 {
h.sendText(ctx, msg.Chat.ID, "Invalid date.")
return
}
loc := h.loadLocation(user.Timezone)
date = domain.UserDateToGregorian(date, user.Calendar)
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
}

169
internal/handler/report.go Normal file
View File

@@ -0,0 +1,169 @@
package handler
import (
"context"
"fmt"
"log/slog"
"time"
tgbot "github.com/go-telegram/bot"
"worktimeBot/internal/domain"
)
func (h *Handler) buildStatusText(user *domain.User) string {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
today := now.Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return "Welcome. Choose an action:"
}
text := fmt.Sprintf("Today: %s", domain.FormatDateForCalendar(day.Date, user.Calendar))
if day.IsDayOff {
text += "\nDay Off"
}
wt, _ := h.Repo.GetWorkType(day.CurrentWorkTypeID)
if wt != nil {
text += fmt.Sprintf("\nWork type: %s", wt.Name)
}
last, err := h.Repo.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
t := time.Unix(last.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
text += fmt.Sprintf("\nStatus: working since %s", t)
} else {
text += "\nStatus: not working"
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err == nil && len(events) > 0 {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, now.Unix())
text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds))
}
st, _ := h.Repo.GetOrCreateSettings(user.ID)
if st != nil && st.HourlyRate > 0 {
if events, err := h.Repo.EventsForDayByDayID(day.ID); err == nil && len(events) > 0 {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, now.Unix())
sal := h.buildSalaryStatus(user, totals.TotalSeconds)
if sal != "" {
text += sal
}
}
}
if st != nil && st.RPGEnabled {
stats, _ := h.Repo.GetOrCreateRPGStats(user.ID)
if stats != nil {
text += fmt.Sprintf("\nLevel: %d | XP: %d", stats.Level, stats.XP)
rankText := h.buildLeagueRankText(user.ID)
if rankText != "" {
text += "\n" + rankText
}
}
}
text += "\n\nChoose an action:"
return text
}
func (h *Handler) buildDailyReport(user *domain.User, day *domain.Day, events []domain.Event) string {
loc := h.loadLocation(user.Timezone)
if len(events) == 0 {
if day.IsDayOff {
return fmt.Sprintf("%s - Day Off", domain.FormatDateForCalendar(day.Date, user.Calendar))
}
return fmt.Sprintf("%s - No events recorded.", domain.FormatDateForCalendar(day.Date, user.Calendar))
}
y, m, d := domain.ParseGregorianDateKey(day.Date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
now := time.Now().In(loc)
todayStr := now.Format(domain.DateLayout)
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
if day.Date == todayStr {
dayEnd = now.Unix()
}
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
lines := fmt.Sprintf("Report for %s", domain.FormatDateForCalendar(day.Date, user.Calendar))
if day.IsDayOff {
lines += "\nDay Off: Yes"
}
lines += "\n\n"
for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
label := "IN"
if e.EventType == "out" {
label = "OUT"
}
suffix := ""
if e.WorkTypeID != nil {
if wtObj, err := h.Repo.GetWorkType(*e.WorkTypeID); err == nil {
suffix = " [" + wtObj.Name + "]"
}
}
if e.Note != "" {
suffix += " (" + e.Note + ")"
}
lines += fmt.Sprintf("%s %s%s\n", label, t, suffix)
}
lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n",
formatDuration(totals.TotalSeconds), formatDuration(totals.BreakSeconds))
st, _ := h.Repo.GetOrCreateSettings(user.ID)
if st != nil && st.HourlyRate > 0 {
est := domain.ComputeDailySalary(totals.TotalSeconds, st.HourlyRate)
s := domain.SalaryEstimate{WorkSeconds: totals.TotalSeconds, GrossEarning: est}
lines += fmt.Sprintf(" Salary: %s\n", s.FormatSalaryShort(st.Currency))
}
return lines
}
func (h *Handler) buildSalaryStatus(user *domain.User, workSeconds int64) string {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil || st.HourlyRate <= 0 {
return ""
}
est := domain.ComputeDailySalary(workSeconds, st.HourlyRate)
s := domain.SalaryEstimate{WorkSeconds: workSeconds, GrossEarning: est, HourlyRate: st.HourlyRate}
return fmt.Sprintf("\nSalary: %s (%.2f/hr)", s.FormatSalaryShort(st.Currency), st.HourlyRate)
}
func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, cbID string) {
defer h.answerCb(ctx, cbID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
h.editText(ctx, chatID, msgID, "Error loading profile", nil)
return
}
kb := mainKeyboard()
h.editText(ctx, chatID, msgID, h.buildStatusText(user), &kb)
}
// SendDailyReport sends a daily report to a specific user (called by scheduler).
func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int64) {
user, err := h.Repo.GetOrCreateUser(chatID)
if err != nil {
slog.Error("SendDailyReport: get user", "chat_id", chatID, "error", err)
return
}
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
slog.Error("SendDailyReport: get day", "chat_id", chatID, "error", err)
return
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil {
slog.Error("SendDailyReport: events", "chat_id", chatID, "error", err)
return
}
text := h.buildDailyReport(user, day, events)
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil {
slog.Warn("SendDailyReport: send", "chat_id", chatID, "error", err)
}
}

402
internal/handler/rpg.go Normal file
View File

@@ -0,0 +1,402 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Commands --
func (h *Handler) handleRPG(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.RPGEnabled {
h.sendText(ctx, msg.Chat.ID, "RPG system is disabled. Enable it in Settings or use /rpgtoggle.")
return
}
text := h.buildRPGStatus(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) handleAchievements(ctx context.Context, msg *models.Message, user *domain.User) {
text := h.buildAchievementsList(user)
h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard())
}
// -- Callbacks --
func (h *Handler) rpgCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildRPGStatus(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) achievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildAchievementsList(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) rpgAchievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildAchievementsList(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to RPG", CallbackData: "rpg_back"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) rpgBackCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildRPGStatus(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- RPG business logic --
func (h *Handler) buildRPGStatus(user *domain.User) string {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil || !st.RPGEnabled {
return "RPG system is disabled."
}
stats, err := h.Repo.GetOrCreateRPGStats(user.ID)
if err != nil {
return "RPG stats unavailable"
}
nextLevelXP := domain.XpForLevel(stats.Level + 1)
currentLevelXP := domain.XpForLevel(stats.Level)
xpIntoLevel := stats.XP - currentLevelXP
xpNeeded := nextLevelXP - currentLevelXP
var b strings.Builder
b.WriteString(fmt.Sprintf("Level: %d\n", stats.Level))
b.WriteString(fmt.Sprintf("XP: %d / %d (%d%%)\n", stats.XP, nextLevelXP, xpIntoLevel*100/xpNeeded))
b.WriteString(fmt.Sprintf("Streak: %d days (best: %d)\n", stats.CurrentStreak, stats.LongestStreak))
hoursTotal := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour
b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal))
achs, _ := h.Repo.GetUserAchievements(user.ID)
if len(achs) > 0 {
b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs)))
}
return b.String()
}
func (h *Handler) buildAchievementsList(user *domain.User) string {
achs, err := h.Repo.GetUserAchievements(user.ID)
if err != nil {
return "Achievements unavailable"
}
if len(achs) == 0 {
return "No achievements unlocked yet.\nWork consistently to earn them!"
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Achievements (%d):\n\n", len(achs)))
for _, a := range achs {
t := time.Unix(a.UnlockedAt, 0)
b.WriteString(fmt.Sprintf(" %s\n %s - %s\n", a.Name, a.Description, t.Format(domain.DateLayout)))
}
return b.String()
}
func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string {
h.updateStreak(stats, user.ID, 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
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
text := fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
if leveledUp {
text += "\nLevel up!"
}
for _, a := range unlocked {
text += fmt.Sprintf("\nAchievement: %s", a)
}
return text
}
func (h *Handler) computeAndAwardXP(stats *domain.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, _ error) {
baseXP := domain.XpForWorkSeconds(workSeconds)
bonus := domain.StreakBonus(streak)
totalXP := baseXP + bonus
if totalXP < 0 {
totalXP = 0
}
stats.XP += totalXP
newLevel = domain.LevelForXP(stats.XP)
leveledUp = newLevel > stats.Level
stats.Level = newLevel
stats.TotalWorkSeconds += workSeconds
if err := h.Repo.UpdateRPGStats(stats); err != nil {
return 0, false, 0, err
}
if err := h.Repo.UpsertDailyXP(userID, date, totalXP, workSeconds); err != nil {
slog.Warn("failed to upsert daily xp", "user_id", userID, "error", err)
}
return stats.XP, leveledUp, newLevel, nil
}
func (h *Handler) updateStreak(stats *domain.RPGStats, userID int64, date string) {
t, err := time.Parse(domain.DateLayout, date)
if err != nil {
return
}
yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout)
prevEvents, err := h.Repo.EventsForDayByDate(userID, yesterday)
hadWorkYesterday := err == nil && len(prevEvents) > 0
if hadWorkYesterday {
stats.CurrentStreak++
} else {
stats.CurrentStreak = 1
}
if stats.CurrentStreak > stats.LongestStreak {
stats.LongestStreak = stats.CurrentStreak
}
}
func (h *Handler) recalcDayWorkSeconds(userID int64, date string, loc *time.Location) {
events, err := h.Repo.EventsForDayByDate(userID, date)
if err != nil {
return
}
day, err := h.Repo.GetDay(userID, date)
if err != nil {
return
}
y, m, d := domain.ParseGregorianDateKey(date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if err := h.Repo.SetDailyWorkSeconds(userID, date, totals.TotalSeconds); err != nil {
slog.Warn("failed to set daily work seconds", "user_id", userID, "date", date, "error", err)
}
}
func (h *Handler) recalcAggregates(userID int64, loc *time.Location) {
total, err := h.Repo.SumDailyWorkSeconds(userID)
if err != nil {
slog.Warn("failed to sum daily work seconds", "user_id", userID, "error", err)
return
}
stats, err := h.Repo.GetOrCreateRPGStats(userID)
if err != nil {
slog.Warn("failed to get rpg stats for recalc", "user_id", userID, "error", err)
return
}
stats.TotalWorkSeconds = total
dates, err := h.Repo.GetDatesWithEvents(userID)
today := time.Now().In(loc).Format(domain.DateLayout)
if err == nil {
stats.CurrentStreak, stats.LongestStreak = domain.ComputeStreakFromDates(dates, today)
} else {
stats.CurrentStreak = 0
stats.LongestStreak = 0
}
if err := h.Repo.UpdateRPGStats(stats); err != nil {
slog.Warn("failed to update rpg stats after recalc", "user_id", userID, "error", err)
}
}
// -- Achievements --
func (h *Handler) checkAchievements(userID int64, stats *domain.RPGStats, workSeconds int64, date string, isWeekend bool, totalHours float64) []string {
u := &achievementUnlocker{h: h, userID: userID}
h.checkEarlyAchievements(u, workSeconds)
h.checkEventTimeAchievements(u, userID, date)
h.checkStreakAchievements(u, stats)
h.checkHourAchievements(u, stats, totalHours)
h.checkSessionAchievements(u, workSeconds, isWeekend)
h.checkLevelAchievements(u, stats)
h.checkConsistencyAchievements(u, userID, date)
h.checkPerfectWeekAchievements(u, userID, date)
h.checkWorkTypeAchievements(u, userID)
return u.unlocked
}
type achievementUnlocker struct {
h *Handler
userID int64
unlocked []string
}
func (u *achievementUnlocker) unlock(code string) {
a, err := u.h.Repo.GetAchievementByCode(code)
if err != nil {
return
}
ok, err := u.h.Repo.UnlockAchievement(u.userID, a.ID)
if err != nil {
slog.Error("unlock achievement", "code", code, "error", err)
return
}
if ok {
u.unlocked = append(u.unlocked, a.Name)
}
}
func (h *Handler) checkEarlyAchievements(u *achievementUnlocker, workSeconds int64) {
has, _ := h.Repo.HasAchievement(u.userID, "first_clockin")
if !has && workSeconds > 0 {
u.unlock("first_clockin")
}
}
func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) {
events, err := h.Repo.EventsForDayByDate(userID, date)
if err != nil {
return
}
for _, e := range events {
et := time.Unix(e.OccurredAt, 0)
hh, mm, _ := et.Clock()
minutes := hh*60 + mm
if e.EventType == "in" && minutes < 7*60 {
u.unlock("early_bird")
}
if e.EventType == "out" && minutes >= 22*60 {
u.unlock("night_owl")
}
}
}
func (h *Handler) checkStreakAchievements(u *achievementUnlocker, stats *domain.RPGStats) {
if stats.CurrentStreak >= 5 {
u.unlock("iron_streak_5")
}
if stats.CurrentStreak >= 10 {
u.unlock("iron_streak_10")
}
if stats.CurrentStreak >= 30 {
u.unlock("iron_streak_30")
}
}
func (h *Handler) checkHourAchievements(u *achievementUnlocker, stats *domain.RPGStats, hoursWorked float64) {
if hoursWorked >= 100.0 {
u.unlock("hundred_hours")
}
if hoursWorked >= 500.0 {
u.unlock("five_hundred_hours")
}
}
func (h *Handler) checkSessionAchievements(u *achievementUnlocker, workSeconds int64, isWeekend bool) {
if workSeconds > 10*domain.SecondsPerHour {
u.unlock("overtime_hero")
}
if isWeekend && workSeconds > 0 {
u.unlock("weekend_warrior")
}
}
func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *domain.RPGStats) {
if stats.Level >= 5 {
u.unlock("level_5")
}
if stats.Level >= 10 {
u.unlock("level_10")
}
if stats.Level >= 25 {
u.unlock("level_25")
}
if stats.Level >= 50 {
u.unlock("level_50")
}
}
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

@@ -0,0 +1,95 @@
package handler
import (
"context"
"fmt"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Command --
func (h *Handler) handleSalary(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.buildSalaryEstimate(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard())
}
// -- Callback --
func (h *Handler) salaryCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.buildSalaryEstimate(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Builders --
func (h *Handler) buildSalaryEstimate(user *domain.User) (string, error) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return "", err
}
if st.HourlyRate <= 0 {
return "No hourly rate configured.\nUse /setrate <amount> to set your rate.", nil
}
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
calY, calM := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
}
startStr, endStr := domain.MonthGregorianRange(user.Calendar, calY, calM)
days, err := h.Repo.GetUserDaysInRange(user.ID, startStr, endStr)
if err != nil {
return "", err
}
est := h.computeMonthlySalary(days, user.ID, st.HourlyRate, loc)
est.Currency = st.Currency
est.HourlyRate = st.HourlyRate
title := domain.FormatMonthTitle(user.Calendar, calY, calM)
return fmt.Sprintf("Salary estimate for %s\n%s\nRate: %s%.2f/hr\nDays worked: %d\nHours: %.1f",
title, est.FormatSalary(st.Currency), domain.CurrencySymbol(st.Currency), st.HourlyRate,
est.WorkDays, est.WorkHours), nil
}
func (h *Handler) computeMonthlySalary(days []domain.Day, userID int64, hourlyRate float64, loc *time.Location) domain.SalaryEstimate {
var totalWork int64
workDayCount := 0
for _, day := range days {
if day.IsDayOff {
continue
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil || len(events) == 0 {
continue
}
y, m, d := domain.ParseGregorianDateKey(day.Date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if totals.TotalSeconds > 0 {
totalWork += totals.TotalSeconds
workDayCount++
}
}
hours := float64(totalWork) / domain.SecondsPerHour
gross := domain.ComputeDailySalary(totalWork, hourlyRate)
return domain.SalaryEstimate{
WorkSeconds: totalWork, WorkHours: hours,
GrossEarning: gross, WorkDays: workDayCount,
}
}

View File

@@ -0,0 +1,828 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- /start and /help --
func (h *Handler) handleStart(ctx context.Context, msg *models.Message, user *domain.User) {
slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID)
kb := mainKeyboard()
h.sendWithKB(ctx, msg.Chat.ID, h.buildStatusText(user), kb)
}
func (h *Handler) handleSettings(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
text := `Commands:
/start - Show main menu
/settings - Open settings
/clockin - Clock in
/clockout - Clock out
/worktype - Select work type
/dayoff - Toggle day off
/report - Show today report
/export [YYYY-MM] - Export monthly report
/history - View calendar history
/edit YYYY-MM-DD - Edit a specific day
/note [YYYY-MM-DD] HH:MM <text> - Set note for an event
/summary [YYYY-MM] - Show monthly summary
/reporttoggle - Enable/disable auto report
/setreporttime HH:MM - Set daily report time
/setaccent - Select accent color
/settimezone <name> - Set timezone (e.g. Asia/Tehran)
/setbreak <minutes> - Set break threshold in minutes
/setusername <name> - Set your display name
/rpg - Show RPG stats and XP
/achievements - View unlocked achievements
/salary - Show salary estimate
/burnout - View burnout assessment
/league - View WorkTime League rankings
/setrate <amount> - Set hourly rate (e.g. 25.50)
/setcurrency <symbol> <code> - Set currency
/rpgtoggle - Enable/disable RPG system
/leaguetoggle - Enable/disable league participation`
h.sendText(ctx, msg.Chat.ID, text)
}
// -- Settings menu callbacks --
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) buildSettingsKeyboard(user *domain.User, st *domain.UserSettings, breakThreshold int64) (string, models.InlineKeyboardMarkup) {
reportStatus := "off"
if st.ReportEnabled {
reportStatus = "on"
}
rpgStatus := "off"
if st.RPGEnabled {
rpgStatus = "on"
}
leagueStatus := "off"
if st.RPGEnabled && st.LeagueOptIn {
leagueStatus = "on"
}
rows := [][]models.InlineKeyboardButton{
{{Text: fmt.Sprintf("Username: %s", user.Username), CallbackData: "username"}},
{
{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"},
{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"},
},
{
{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"},
{Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"},
},
{
{Text: fmt.Sprintf("Break: %dm", breakThreshold/60), CallbackData: "breakthreshold"},
{Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"},
},
{
{Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"},
{Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"},
},
{
{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"},
{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"},
},
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
}
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Toggle callbacks (binary select pickers) --
func (h *Handler) rpgToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, kb := h.buildToggleKeyboard(user.ID, "rpg", "RPG System")
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) leagueToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if !st.RPGEnabled {
kb := settingsBackKeyboard()
h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", &kb)
return
}
text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League")
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) reportToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, kb := h.buildToggleKeyboard(user.ID, "report", "Daily Report")
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) buildToggleKeyboard(userID int64, setting, label string) (string, models.InlineKeyboardMarkup) {
st, err := h.Repo.GetOrCreateSettings(userID)
current := false
if err == nil {
switch setting {
case "rpg":
current = st.RPGEnabled
case "league":
current = st.LeagueOptIn
case "report":
current = st.ReportEnabled
}
}
options := []struct {
label string
data string
value bool
}{
{"Enabled", setting + "_enable", true},
{"Disabled", setting + "_disable", false},
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range options {
l := opt.label
if opt.value == current {
l = "> " + l
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: l, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return label + ":", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) setRPGCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.RPGEnabled = enable
if enable {
h.Repo.GetOrCreateRPGStats(user.ID)
h.tryUnlockAchievement(user.ID, "rpg_pioneer")
} else {
st.LeagueOptIn = false
}
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) setLeagueCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if enable && !st.RPGEnabled {
h.answerCbWithText(ctx, cbID, "Enable RPG first")
return
}
st.LeagueOptIn = enable
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) setReportToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.ReportEnabled = enable
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Prompt callbacks (settings items that need text input) --
func (h *Handler) usernameCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingUsername,
fmt.Sprintf("Current username: %s\n\nSend me the new username:", user.Username), nil)
}
func (h *Handler) rateCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingRate,
"Send me the hourly rate (e.g. 25.50):", nil)
}
func (h *Handler) currencyCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingCurrency,
"Send me the currency (symbol and code, e.g. $ USD):\nPresets: $ USD, T Toman, IRR Rial, EUR, GBP", nil)
}
func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingTimezone,
fmt.Sprintf("Current timezone: %s\n\nSend me the IANA timezone (e.g. Asia/Tehran, Europe/London):", user.Timezone), nil)
}
func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingBreak,
"Send me the break threshold in minutes (0-480, e.g. 15):", nil)
}
func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, _ := h.Repo.GetOrCreateSettings(user.ID)
current := "08:00"
if st != nil {
current = st.ReportTime
}
h.promptForInput(ctx, chatID, msgID, user.ID, PendingReportTime,
fmt.Sprintf("Current report time: %s\n\nSend me the report time in HH:MM (24-hour):", current), nil)
}
func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, kb := h.buildCalendarTypeKeyboard(user)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildAccentKeyboard(st)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Accent picker --
func (h *Handler) buildAccentKeyboard(st *domain.UserSettings) (string, models.InlineKeyboardMarkup) {
type accentOption struct{ name, color string }
accents := []accentOption{
{"ocean", "Blue"},
{"beach", "Warm"},
{"rose", "Pink"},
{"catppuccin", "Purple"},
}
rows := [][]models.InlineKeyboardButton{}
for _, a := range accents {
label := fmt.Sprintf("%s (%s)", a.name, a.color)
if a.name == st.ExportAccent {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "accent_" + a.name},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select accent color:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, name string) {
defer h.answerCb(ctx, cbID)
valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true}
if !valid[name] {
return
}
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.ExportAccent = name
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Calendar type picker --
func (h *Handler) buildCalendarTypeKeyboard(user *domain.User) (string, models.InlineKeyboardMarkup) {
cals := []string{"gregorian", "jalali", "hijri"}
calLabels := map[string]string{
"gregorian": "Gregorian",
"jalali": "Jalali",
"hijri": "Hijri",
}
rows := [][]models.InlineKeyboardButton{}
for _, c := range cals {
label := calLabels[c]
if c == user.Calendar {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "caltype_" + c},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select calendar type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, name string) {
defer h.answerCb(ctx, cbID)
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
if !valid[name] {
return
}
user.Calendar = name
if err := h.Repo.UpdateUser(user); err != nil {
return
}
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Break threshold select (preset picker) --
func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, valStr string) {
defer h.answerCb(ctx, cbID)
mins, err := strconv.Atoi(valStr)
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
return
}
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return
}
day.MinBreakThreshold = int64(mins) * 60
h.Repo.UpdateDay(day)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Currency preset picker --
func (h *Handler) selectCurrency(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, currency string) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.Currency = currency
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Command implementations for settings (prompt-and-wait OR direct args) --
func (h *Handler) handleSetRate(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setrate"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingRate,
"Send me the hourly rate (e.g. 25.50):", nil)
return
}
var rate float64
if _, err := fmt.Sscanf(args, "%f", &rate); err != nil || rate < 0 || rate > domain.MaxHourlyRate {
h.sendText(ctx, msg.Chat.ID, "Invalid rate. Use a positive number (e.g. /setrate 25.50)")
return
}
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) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingCurrency,
"Send me the currency (symbol and code, e.g. $ USD):", nil)
return
}
currency, errMsg := domain.ParseCurrencyInput(args)
if errMsg != "" {
h.sendText(ctx, msg.Chat.ID, errMsg)
return
}
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) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/settimezone"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingTimezone,
fmt.Sprintf("Current timezone: %s\n\nSend me the IANA timezone:", user.Timezone), nil)
return
}
if _, err := time.LoadLocation(args); err != nil {
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran", args))
return
}
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) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setbreak"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingBreak,
"Send me the break threshold in minutes (0-480):", nil)
return
}
mins, err := strconv.Atoi(args)
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
h.sendText(ctx, msg.Chat.ID, "Invalid value. Must be between 0 and 480 minutes.")
return
}
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) {
name := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setusername"))
if name == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingUsername,
fmt.Sprintf("Current username: %s\n\nSend me the new username:", user.Username), nil)
return
}
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) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setreporttime"))
if args == "" {
st, _ := h.Repo.GetOrCreateSettings(user.ID)
current := "08:00"
if st != nil {
current = st.ReportTime
}
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingReportTime,
fmt.Sprintf("Current report time: %s\n\nSend me the report time in HH:MM:", current), nil)
return
}
if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, args); err != nil {
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) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
text, kb := h.buildAccentKeyboard(st)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) handleRPGSettings(ctx context.Context, msg *models.Message, user *domain.User) {
text, kb := h.buildToggleKeyboard(user.ID, "rpg", "RPG System")
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) handleLeagueToggle(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.RPGEnabled {
h.sendText(ctx, msg.Chat.ID, "League requires the RPG system. Enable RPG first with /rpgtoggle.")
return
}
text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League")
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// -- Pending input processors --
func (h *Handler) processRateInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
var rate float64
if _, err := fmt.Sscanf(text, "%f", &rate); err != nil || rate < 0 || rate > domain.MaxHourlyRate {
h.editText(ctx, msg.Chat.ID, pi.MsgID, "Invalid rate. Use a positive number.", &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "setrate"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveRate(ctx, msg.Chat.ID, user, rate)
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,
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) {
currency, errMsg := domain.ParseCurrencyInput(text)
if errMsg != "" {
h.editText(ctx, msg.Chat.ID, pi.MsgID, errMsg, &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "currency"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveCurrency(ctx, msg.Chat.ID, user, currency)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Currency set to: %s", currency), &backKB)
}
func (h *Handler) processTimezoneInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
if _, err := time.LoadLocation(text); err != nil {
h.editText(ctx, msg.Chat.ID, pi.MsgID,
fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran", text), &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "timezone"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveTimezone(ctx, msg.Chat.ID, user, text)
loc := h.loadLocation(text)
now := time.Now().In(loc)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID,
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) {
mins, err := strconv.Atoi(text)
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
h.editText(ctx, msg.Chat.ID, pi.MsgID,
"Invalid value. Must be between 0 and 480 minutes.", &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "breakthreshold"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins)
st, _ := h.Repo.GetOrCreateSettings(user.ID)
bt := h.getTodayBreakThreshold(user)
text2, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, msg.Chat.ID, pi.MsgID, text2, &kb)
}
func (h *Handler) processUsernameInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
h.saveUsername(ctx, msg.Chat.ID, user, text)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Username set to: %s", text), &backKB)
}
func (h *Handler) processReportTimeInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, text); err != nil {
h.editText(ctx, msg.Chat.ID, pi.MsgID, err.Error(), &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "reporttime"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Report time set to %s", text), &backKB)
}
func (h *Handler) processNoteInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
eventIDStr, ok := pi.Data["event_id"]
if !ok {
return
}
var eventID int64
if _, err := fmt.Sscanf(eventIDStr, "%d", &eventID); err != nil {
return
}
note := domain.SanitizeNote(text)
if err := h.Repo.SetEventNote(eventID, note); err != nil {
slog.Error("failed to save event note", "event_id", eventID, "error", err)
return
}
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
loc := h.loadLocation(user.Timezone)
date := time.Unix(event.OccurredAt, 0).In(loc).Format(domain.DateLayout)
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
}
// -- Shared save helpers --
func (h *Handler) saveRate(ctx context.Context, chatID int64, user *domain.User, rate float64) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, chatID, "Error saving rate")
return
}
wasZero := st.HourlyRate == 0
st.HourlyRate = float64(int(rate*domain.SalaryRoundFactor)) / 100.0
if err := h.Repo.UpdateSettings(st); err != nil {
h.sendText(ctx, chatID, "Error saving rate")
return
}
if wasZero {
h.tryUnlockAchievement(user.ID, "salary_setter")
}
}
func (h *Handler) saveCurrency(_ context.Context, _ int64, user *domain.User, currency string) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
slog.Error("save currency: get settings failed", "user_id", user.ID, "error", err)
return
}
st.Currency = currency
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) {
user.Timezone = tz
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) {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
slog.Error("save break threshold: get day failed", "user_id", user.ID, "error", err)
return
}
day.MinBreakThreshold = int64(mins) * 60
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) {
name = domain.SanitizeDisplayName(name)
if name == "" {
return
}
user.Username = name
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 {
if len(timeStr) != 5 || timeStr[2] != ':' {
return fmt.Errorf("invalid format. Use HH:MM (24-hour)")
}
hours, err1 := strconv.Atoi(timeStr[:2])
mins, err2 := strconv.Atoi(timeStr[3:])
if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 {
return fmt.Errorf("invalid time. Use HH:MM (24-hour)")
}
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return fmt.Errorf("error saving report time")
}
st.ReportTime = timeStr
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)
}

120
internal/handler/summary.go Normal file
View File

@@ -0,0 +1,120 @@
package handler
import (
"context"
"fmt"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
func (h *Handler) handleSummary(ctx context.Context, msg *models.Message, user *domain.User) {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
args := strings.Fields(msg.Text)
if len(args) >= 1 && args[0][0] == '/' {
args = args[1:]
}
var calY, calM int
if len(args) >= 1 {
if _, err := fmt.Sscanf(args[0], "%d-%d", &calY, &calM); err != nil || calM < 1 || calM > 12 {
h.sendText(ctx, msg.Chat.ID, "Usage: /summary [YYYY-MM] (e.g. /summary 2026-06)")
return
}
} else {
calY, calM = now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
}
}
startStr, endStr := domain.MonthGregorianRange(user.Calendar, calY, calM)
summary := h.buildMonthlySummary(user, calY, calM, startStr, endStr, loc)
h.sendText(ctx, msg.Chat.ID, summary)
}
func (h *Handler) buildMonthlySummary(user *domain.User, calY, calM int, startStr, endStr string, loc *time.Location) string {
days, err := h.Repo.GetUserDaysInRange(user.ID, startStr, endStr)
if err != nil {
return "Error loading days"
}
title := domain.FormatMonthTitle(user.Calendar, calY, calM)
totalWork := int64(0)
totalBreak := int64(0)
dayOffCount := 0
workDayCount := 0
dayDetails := []string{}
for _, day := range days {
dayLabel := domain.FormatDateForCalendar(day.Date, user.Calendar)
if day.IsDayOff {
dayOffCount++
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil || len(events) == 0 {
if day.IsDayOff {
dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel))
}
continue
}
y, m, d := domain.ParseGregorianDateKey(day.Date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if totals.TotalSeconds == 0 {
if day.IsDayOff {
dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel))
}
continue
}
workDayCount++
totalWork += totals.TotalSeconds
totalBreak += totals.BreakSeconds
label := fmt.Sprintf(" %s - %s worked", dayLabel, formatDuration(totals.TotalSeconds))
if day.IsDayOff {
label += " (Day Off)"
}
dayDetails = append(dayDetails, label)
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Summary for %s\n", title))
b.WriteString(fmt.Sprintf("Work days: %d\n", workDayCount))
if dayOffCount > 0 {
b.WriteString(fmt.Sprintf("Days off: %d\n", dayOffCount))
}
b.WriteString(fmt.Sprintf("Total: %s worked", formatDuration(totalWork)))
if totalBreak > 0 {
b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak)))
}
st, _ := h.Repo.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
est := h.computeMonthlySalary(days, user.ID, st.HourlyRate, loc)
b.WriteString(fmt.Sprintf("\nSalary: %s (%.1f hours, %d days)",
est.FormatSalaryShort(st.Currency), est.WorkHours, est.WorkDays))
} else {
b.WriteString("\nSalary: No rate configured. Use /setrate <amount>")
}
b.WriteString("\n\nDetails:\n")
for _, d := range dayDetails {
b.WriteString(d)
b.WriteString("\n")
}
return b.String()
}

View File

@@ -0,0 +1,75 @@
package handler
import (
"context"
"fmt"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
func (h *Handler) handleWorkType(ctx context.Context, msg *models.Message, user *domain.User) {
day, err := h.getTodayDay(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading work types")
return
}
text, kb := h.buildWorkTypeKeyboard(user, day)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) workTypeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
day, err := h.getTodayDay(user)
if err != nil {
kb := backKeyboard()
h.editText(ctx, chatID, msgID, "Error loading work types", &kb)
return
}
text, kb := h.buildWorkTypeKeyboard(user, day)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) buildWorkTypeKeyboard(user *domain.User, day *domain.Day) (string, models.InlineKeyboardMarkup) {
wts, err := h.Repo.GetWorkTypes()
if err != nil || len(wts) == 0 {
return "No work types available.", backKeyboard()
}
rows := [][]models.InlineKeyboardButton{}
for _, wt := range wts {
label := wt.Name
if wt.ID == day.CurrentWorkTypeID {
label = "> " + wt.Name
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: fmt.Sprintf("worktype_%d", wt.ID)},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
return "Select work type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) selectWorkType(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, idStr string) {
defer h.answerCb(ctx, cbID)
day, err := h.getTodayDay(user)
if err != nil {
return
}
var wtID int64
if _, err := fmt.Sscanf(idStr, "%d", &wtID); err != nil {
return
}
wt, err := h.Repo.GetWorkType(wtID)
if err != nil {
return
}
day.CurrentWorkTypeID = wtID
if err := h.Repo.UpdateDay(day); err != nil {
return
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, fmt.Sprintf("Work type set to: %s", wt.Name), &kb)
}

View File

@@ -0,0 +1,62 @@
package repo
import "worktimeBot/internal/domain"
type Repository interface {
// Users
GetOrCreateUser(chatID int64) (*domain.User, error)
UpdateUser(user *domain.User) error
GetAllUsers() ([]domain.User, error)
// Settings
GetOrCreateSettings(userID int64) (*domain.UserSettings, error)
UpdateSettings(st *domain.UserSettings) error
MarkReportSent(userID int64, date string) error
// Days
GetOrCreateDay(userID int64, date string) (*domain.Day, error)
GetDay(userID int64, date string) (*domain.Day, error)
UpdateDay(day *domain.Day) error
GetUserDaysInRange(userID int64, start, end string) ([]domain.Day, error)
SetDayOff(userID int64, date, reason string) error
RemoveDayOff(userID int64, date string) error
// Events
CreateEvent(userID, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error
GetLastEvent(userID int64) (*domain.Event, error)
EventsForDayByDate(userID int64, date string) ([]domain.Event, error)
EventsForDayByDayID(dayID int64) ([]domain.Event, error)
GetEventByID(eventID, userID int64) (*domain.Event, error)
UpdateEventTime(eventID int64, occurredAt int64) error
UpdateEventWorkType(eventID, workTypeID int64) error
SetEventNote(eventID int64, note string) error
DeleteEvent(eventID int64) error
GetDistinctEventDates(userID int64, start, end string) ([]string, error)
GetDatesWithEvents(userID int64) ([]string, error)
// Work Types
GetWorkTypes() ([]domain.WorkType, error)
GetWorkType(id int64) (*domain.WorkType, error)
// RPG
GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error)
UpdateRPGStats(stats *domain.RPGStats) error
UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error
SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error
SumDailyWorkSeconds(userID int64) (int64, error)
GetDailyXPInRange(userID int64, start, end string) ([]domain.DailyXP, error)
SumWorkSecondsByWorkType(userID int64) (remoteSeconds, onsiteSeconds int64, _ error)
// Achievements
GetAchievementByCode(code string) (*domain.Achievement, error)
UnlockAchievement(userID, achievementID int64) (bool, error)
HasAchievement(userID int64, code string) (bool, error)
GetUserAchievements(userID int64) ([]domain.UserAchievement, error)
// League
GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error)
// Lifecycle
DeleteUserData(userID int64) error
Close() error
}

221
internal/repo/migrations.go Normal file
View File

@@ -0,0 +1,221 @@
package repo
import (
"database/sql"
"embed"
"log/slog"
"time"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// runMigrations handles all database migrations using goose.
func runMigrations(db *sql.DB) error {
needsOldMigrate := hasTable(db, "events_old") || (hasTable(db, "events") && !hasCol(db, "events", "user_id"))
if needsOldMigrate {
slog.Info("migrating from old schema to new schema")
for _, t := range []string{"events", "days_off", "settings"} {
if hasTable(db, t) {
if _, err := db.Exec("ALTER TABLE " + t + " RENAME TO " + t + "_old"); err != nil {
return err
}
}
}
}
goose.SetBaseFS(migrationFS)
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
if err := goose.Up(db, "migrations"); err != nil {
return err
}
if needsOldMigrate {
if err := migrateFromOldSchemaData(db); err != nil {
return err
}
}
return nil
}
func hasTable(db *sql.DB, name string) bool {
var exists int
if err := db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&exists); err != nil {
return false
}
return exists > 0
}
func hasCol(db *sql.DB, table, column string) bool {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false
}
if name == column {
return true
}
}
return false
}
func migrateFromOldSchemaData(db *sql.DB) error {
migrateUsers(db)
migrateDaysOff(db)
migrateEvents(db)
migrateSettings(db)
for _, t := range []string{"days_off_old", "settings_old", "events_old"} {
if hasTable(db, t) {
if _, err := db.Exec("DROP TABLE IF EXISTS " + t); err != nil {
slog.Warn("failed to drop old table", "table", t, "error", err)
}
}
}
slog.Info("migration complete")
return nil
}
func migrateUsers(db *sql.DB) {
if !hasTable(db, "events_old") {
return
}
rows, err := db.Query("SELECT DISTINCT chat_id FROM events_old")
if err != nil {
slog.Warn("migration: failed to query events_old for users", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
if err := rows.Scan(&chatID); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
if _, err := db.Exec(
"INSERT OR IGNORE INTO users (chat_id, timezone) VALUES (?, 'Asia/Tehran')",
chatID,
); err != nil {
slog.Warn("migration: failed to insert user", "chat_id", chatID, "error", err)
}
}
}
func migrateDaysOff(db *sql.DB) {
if !hasTable(db, "days_off_old") {
return
}
rows, err := db.Query("SELECT chat_id, date, reason FROM days_off_old")
if err != nil {
slog.Warn("migration: failed to query days_off_old", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
var date, reason string
if err := rows.Scan(&chatID, &date, &reason); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
var userID int64
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
continue
}
if _, err := db.Exec(
"INSERT INTO days (user_id, date, is_day_off, day_off_reason) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, date) DO UPDATE SET is_day_off=1, day_off_reason=excluded.day_off_reason",
userID, date, reason,
); err != nil {
slog.Warn("migration: failed to insert day_off", "user_id", userID, "date", date, "error", err)
}
}
}
func migrateEvents(db *sql.DB) {
if !hasTable(db, "events_old") {
return
}
rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events_old ORDER BY occurred_at ASC")
if err != nil {
slog.Warn("migration: failed to query events_old", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
var eventType, note string
var occurredAt, createdAt int64
if err := rows.Scan(&chatID, &eventType, &occurredAt, &note, &createdAt); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
var userID int64
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
continue
}
date := time.Unix(occurredAt, 0).UTC().Format("2006-01-02")
if _, err := db.Exec("INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date); err != nil {
slog.Warn("migration: failed to insert day", "user_id", userID, "date", date, "error", err)
continue
}
var dayID int64
if err := db.QueryRow("SELECT id FROM days WHERE user_id=? AND date=?", userID, date).Scan(&dayID); err != nil {
slog.Warn("migration: day not found", "user_id", userID, "date", date, "error", err)
continue
}
if _, err := db.Exec(
"INSERT INTO events (user_id, day_id, event_type, occurred_at, note, created_at) VALUES (?, ?, ?, ?, ?, ?)",
userID, dayID, eventType, occurredAt, note, createdAt,
); err != nil {
slog.Warn("migration: failed to insert event", "user_id", userID, "error", err)
}
}
}
func migrateSettings(db *sql.DB) {
if !hasTable(db, "settings_old") {
return
}
rows, err := db.Query("SELECT chat_id, key, value FROM settings_old")
if err != nil {
slog.Warn("migration: failed to query settings_old", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
var key, value string
if err := rows.Scan(&chatID, &key, &value); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
if key == "remote_flag" {
var userID int64
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
continue
}
wt := int64(1)
if value == "remote" {
wt = 2
}
db.Exec("UPDATE days SET current_work_type_id=? WHERE user_id=? AND date=?", wt, userID, time.Now().UTC().Format("2006-01-02"))
}
}
}

View File

@@ -0,0 +1,59 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS work_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
INSERT OR IGNORE INTO work_types (id, name) VALUES (1, 'onsite');
INSERT OR IGNORE INTO work_types (id, name) VALUES (2, 'remote');
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL UNIQUE,
timezone TEXT NOT NULL DEFAULT 'Asia/Tehran',
is_active INTEGER NOT NULL DEFAULT 1,
report_enabled INTEGER NOT NULL DEFAULT 1,
report_time TEXT NOT NULL DEFAULT '23:00',
last_report_date TEXT DEFAULT NULL,
export_accent TEXT NOT NULL DEFAULT 'ocean'
CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')),
calendar TEXT NOT NULL DEFAULT 'gregorian'
CHECK (calendar IN ('gregorian', 'jalali', 'hijri')),
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
date TEXT NOT NULL CHECK (date = strftime('%Y-%m-%d', date)),
is_day_off INTEGER NOT NULL DEFAULT 0,
day_off_reason TEXT NOT NULL DEFAULT '',
current_work_type_id INTEGER NOT NULL DEFAULT 1 REFERENCES work_types(id),
min_break_threshold INTEGER NOT NULL DEFAULT 300,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(user_id, date)
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
day_id INTEGER NOT NULL REFERENCES days(id),
event_type TEXT NOT NULL CHECK (event_type IN ('in', 'out')),
work_type_id INTEGER REFERENCES work_types(id),
occurred_at INTEGER NOT NULL CHECK (occurred_at > 0),
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_events_user_day ON events(user_id, day_id);
CREATE INDEX IF NOT EXISTS idx_events_user_occurred ON events(user_id, occurred_at);
CREATE INDEX IF NOT EXISTS idx_days_user_date ON days(user_id, date);
-- +goose Down
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS days;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS work_types;

View File

@@ -0,0 +1,101 @@
-- +goose Up
ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
export_accent TEXT NOT NULL DEFAULT 'ocean'
CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')),
report_enabled INTEGER NOT NULL DEFAULT 1,
report_time TEXT NOT NULL DEFAULT '23:00',
last_report_date TEXT NOT NULL DEFAULT '',
rpg_enabled INTEGER NOT NULL DEFAULT 0,
league_opt_in INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT '$ USD',
hourly_rate REAL NOT NULL DEFAULT 0.0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
INSERT OR IGNORE INTO user_settings (user_id)
SELECT id FROM users;
CREATE TABLE IF NOT EXISTS user_rpg (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
xp INTEGER NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 1,
current_streak INTEGER NOT NULL DEFAULT 0,
longest_streak INTEGER NOT NULL DEFAULT 0,
total_work_seconds INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
INSERT OR IGNORE INTO user_rpg (user_id)
SELECT id FROM users;
CREATE TABLE IF NOT EXISTS daily_xp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
date TEXT NOT NULL,
xp_earned INTEGER NOT NULL DEFAULT 0,
work_seconds INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(user_id, date)
);
CREATE TABLE IF NOT EXISTS achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT ''
);
INSERT OR IGNORE INTO achievements (code, name, description) VALUES
('first_clockin', 'First Steps', 'Clock in for the first time'),
('early_bird', 'Early Bird', 'Clock in before 7 AM'),
('night_owl', 'Night Owl', 'Clock out after 10 PM'),
('iron_streak_5', 'Iron Streak', 'Work 5 consecutive days'),
('iron_streak_10', 'Iron Streak II', 'Work 10 consecutive days'),
('iron_streak_30', 'Iron Streak III', 'Work 30 consecutive days'),
('remote_veteran', 'Remote Veteran', 'Log 100 hours of remote work'),
('onsite_master', 'Onsite Master', 'Log 100 hours of onsite work'),
('consistency_master', 'Consistency Master', 'Work at least 6 hours daily for a week'),
('perfect_week', 'Perfect Week', 'No days off for a full week'),
('overtime_hero', 'Overtime Hero', 'Work more than 10 hours in a day'),
('level_5', 'Getting Started', 'Reach level 5'),
('level_10', 'Dedicated', 'Reach level 10'),
('level_25', 'Veteran', 'Reach level 25'),
('level_50', 'Legend', 'Reach level 50'),
('weekend_warrior', 'Weekend Warrior', 'Work on a weekend day'),
('hundred_hours', 'Century', 'Log 100 total work hours'),
('five_hundred_hours', 'Iron Will', 'Log 500 total work hours'),
('salary_setter', 'Salary Tracker', 'Configure your hourly rate'),
('rpg_pioneer', 'RPG Pioneer', 'Enable the RPG system');
CREATE TABLE IF NOT EXISTS user_achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
achievement_id INTEGER NOT NULL REFERENCES achievements(id),
unlocked_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(user_id, achievement_id)
);
CREATE INDEX IF NOT EXISTS idx_daily_xp_user_date ON daily_xp(user_id, date);
CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id);
-- +goose Down
DROP TABLE IF EXISTS user_achievements;
DROP TABLE IF EXISTS achievements;
DROP TABLE IF EXISTS daily_xp;
DROP TABLE IF EXISTS user_rpg;
DROP TABLE IF EXISTS user_settings;
CREATE TABLE IF NOT EXISTS users_old AS SELECT * FROM users;
DROP TABLE IF EXISTS users;
-- Note: restoring users table without the username column requires
-- recreating it from the backup. In practice this migration is safe
-- and the down path preserves data via the backup table.
ALTER TABLE users_old RENAME TO users;

696
internal/repo/store.go Normal file
View File

@@ -0,0 +1,696 @@
package repo
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
_ "modernc.org/sqlite"
"worktimeBot/internal/domain"
)
const driverName = "sqlite"
type Store struct {
db *sql.DB
}
func NewStore(path string) (*Store, error) {
db, err := sql.Open(driverName, path)
if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping: %w", err)
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
slog.Warn("failed to set WAL mode", "error", err)
}
if err := runMigrations(db); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
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 {
return s.db.Close()
}
var usernames = []string{
"Lazy Dev", "Solo Leveler", "Local = Prod", "Database Rat",
"Refactoring Perfectionist", "PHP 5 Cultist", "Java Script Slave",
"AI Slop", "OOP Cultist", "Functional Purist",
"Merge Conflict Survivor", "Stack Overflow Diplomat",
"NULL Pointer", "Memory Leak", "Segmentation Fault",
"Infinite Looper", "TypeScript Wizard", "CSS Box Model Gambler",
"Docker Compose Gambler", "YAML Engineer",
"Production Pusher", "Test Skipper", "Code Review Dodger",
"Commit --force User", "Git Merge Master",
"Dependency Hell Dweller", "Deprecation Warning",
"Legacy Code Whisperer", "Agile Scrum Lord", "Stand Up Sitter",
"Sprint Burnout", "Ticket Creator Supreme",
"Technical Debt Collector", "Documentation? Never Heard",
"Stack Trace Reader", "Binary Search Debugger", "Rubber Duck",
"Hotfix Hero", "Feature Flag Addict",
}
func randomUsername() string {
return usernames[time.Now().UnixNano()%int64(len(usernames))]
}
func (s *Store) GetOrCreateUser(chatID int64) (*domain.User, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)",
chatID, randomUsername(),
); err != nil {
return nil, fmt.Errorf("get or create user: %w", err)
}
return s.GetUserByChatID(chatID)
}
func (s *Store) GetUserByChatID(chatID int64) (*domain.User, error) {
row := s.db.QueryRow(`
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
FROM users WHERE chat_id = ?
`, chatID)
var u domain.User
if err := row.Scan(
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("get user by chat id: %w", err)
}
return &u, nil
}
func (s *Store) UpdateUser(user *domain.User) error {
_, err := s.db.Exec(
"UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?",
user.Username, user.Timezone, user.IsActive, user.Calendar, user.ID,
)
if err != nil {
return fmt.Errorf("update user: %w", err)
}
return nil
}
func (s *Store) GetAllUsers() ([]domain.User, error) {
rows, err := s.db.Query(`
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
FROM users
`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []domain.User
for rows.Next() {
var u domain.User
if err := rows.Scan(
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
// Settings
func (s *Store) GetOrCreateSettings(userID int64) (*domain.UserSettings, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)",
userID,
); err != nil {
return nil, err
}
return s.getSettings(userID)
}
func (s *Store) getSettings(userID int64) (*domain.UserSettings, error) {
row := s.db.QueryRow(`
SELECT user_id, export_accent, report_enabled, report_time, last_report_date,
rpg_enabled, league_opt_in, currency, hourly_rate, created_at, updated_at
FROM user_settings WHERE user_id = ?
`, userID)
var st domain.UserSettings
if err := row.Scan(
&st.UserID, &st.ExportAccent, &st.ReportEnabled, &st.ReportTime, &st.LastReportDate,
&st.RPGEnabled, &st.LeagueOptIn, &st.Currency, &st.HourlyRate, &st.CreatedAt, &st.UpdatedAt,
); err != nil {
return nil, err
}
return &st, nil
}
func (s *Store) UpdateSettings(st *domain.UserSettings) error {
_, err := s.db.Exec(`
UPDATE user_settings SET export_accent=?, report_enabled=?, report_time=?,
last_report_date=?, rpg_enabled=?, league_opt_in=?, currency=?,
hourly_rate=?, updated_at=unixepoch()
WHERE user_id=?
`, st.ExportAccent, st.ReportEnabled, st.ReportTime, st.LastReportDate,
st.RPGEnabled, st.LeagueOptIn, st.Currency, st.HourlyRate, st.UserID)
return err
}
func (s *Store) MarkReportSent(userID int64, date string) error {
st, err := s.GetOrCreateSettings(userID)
if err != nil {
return err
}
st.LastReportDate = date
return s.UpdateSettings(st)
}
// RPG Stats
func (s *Store) GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO user_rpg (user_id) VALUES (?)",
userID,
); err != nil {
return nil, err
}
return s.getRPGStats(userID)
}
func (s *Store) getRPGStats(userID int64) (*domain.RPGStats, error) {
row := s.db.QueryRow(`
SELECT user_id, xp, level, current_streak, longest_streak, total_work_seconds,
created_at, updated_at
FROM user_rpg WHERE user_id = ?
`, userID)
var st domain.RPGStats
if err := row.Scan(
&st.UserID, &st.XP, &st.Level, &st.CurrentStreak, &st.LongestStreak,
&st.TotalWorkSeconds, &st.CreatedAt, &st.UpdatedAt,
); err != nil {
return nil, err
}
return &st, nil
}
func (s *Store) UpdateRPGStats(st *domain.RPGStats) error {
_, err := s.db.Exec(`
UPDATE user_rpg SET xp=?, level=?, current_streak=?, longest_streak=?,
total_work_seconds=?, updated_at=unixepoch()
WHERE user_id=?
`, st.XP, st.Level, st.CurrentStreak, st.LongestStreak, st.TotalWorkSeconds, st.UserID)
return err
}
// Daily XP
func (s *Store) UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error {
_, err := s.db.Exec(`
INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, date) DO UPDATE SET
xp_earned = xp_earned + ?,
work_seconds = work_seconds + ?
`, userID, date, xpEarned, workSeconds, xpEarned, workSeconds)
return err
}
func (s *Store) SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error {
_, err := s.db.Exec(`
INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds)
VALUES (?, ?, 0, ?)
ON CONFLICT(user_id, date) DO UPDATE SET
work_seconds = ?
`, userID, date, workSeconds, workSeconds)
return err
}
func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) {
var total int64
err := s.db.QueryRow(
"SELECT COALESCE(SUM(work_seconds), 0) FROM daily_xp WHERE user_id=?", userID,
).Scan(&total)
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
func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) {
var a domain.Achievement
err := s.db.QueryRow(
"SELECT id, code, name, description, icon FROM achievements WHERE code=?",
code,
).Scan(&a.ID, &a.Code, &a.Name, &a.Description, &a.Icon)
if err != nil {
return nil, err
}
return &a, nil
}
func (s *Store) GetUserAchievements(userID int64) ([]domain.UserAchievement, error) {
rows, err := s.db.Query(`
SELECT ua.id, ua.user_id, ua.achievement_id, a.code, a.name, a.description, ua.unlocked_at
FROM user_achievements ua
JOIN achievements a ON a.id = ua.achievement_id
WHERE ua.user_id = ?
ORDER BY ua.unlocked_at ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var uas []domain.UserAchievement
for rows.Next() {
var ua domain.UserAchievement
if err := rows.Scan(&ua.ID, &ua.UserID, &ua.AchievementID, &ua.Code, &ua.Name, &ua.Description, &ua.UnlockedAt); err != nil {
return nil, err
}
uas = append(uas, ua)
}
return uas, rows.Err()
}
func (s *Store) UnlockAchievement(userID int64, achievementID int64) (bool, error) {
res, err := s.db.Exec(
"INSERT OR IGNORE INTO user_achievements (user_id, achievement_id) VALUES (?, ?)",
userID, achievementID,
)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func (s *Store) HasAchievement(userID int64, code string) (bool, error) {
var count int
err := s.db.QueryRow(`
SELECT COUNT(1) FROM user_achievements ua
JOIN achievements a ON a.id = ua.achievement_id
WHERE ua.user_id=? AND a.code=?
`, userID, code).Scan(&count)
return count > 0, err
}
// League
func (s *Store) GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error) {
validOrders := map[string]bool{"xp": true, "level": true, "streak": true, "hours": true}
if !validOrders[orderBy] {
orderBy = "xp"
}
col := "r.xp"
switch orderBy {
case "level":
col = "r.level"
case "streak":
col = "r.current_streak"
case "hours":
col = "r.total_work_seconds"
}
query := fmt.Sprintf(`
SELECT u.id, u.username, r.xp, r.level, r.current_streak, r.total_work_seconds
FROM user_rpg r
JOIN users u ON u.id = r.user_id
JOIN user_settings s ON s.user_id = r.user_id
WHERE s.league_opt_in = 1
ORDER BY %s DESC, u.username ASC
`, col)
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []domain.LeagueEntry
for rows.Next() {
var e domain.LeagueEntry
if err := rows.Scan(&e.UserID, &e.Username, &e.XP, &e.Level, &e.Streak, &e.TotalHours); err != nil {
return nil, err
}
e.TotalHours = float64(e.TotalHours) / 3600.0
entries = append(entries, e)
}
return entries, rows.Err()
}
// Work Types
func (s *Store) GetWorkTypes() ([]domain.WorkType, error) {
rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")
if err != nil {
return nil, err
}
defer rows.Close()
var wts []domain.WorkType
for rows.Next() {
var wt domain.WorkType
if err := rows.Scan(&wt.ID, &wt.Name, &wt.CreatedAt); err != nil {
return nil, err
}
wts = append(wts, wt)
}
return wts, rows.Err()
}
func (s *Store) GetWorkType(id int64) (*domain.WorkType, error) {
var wt domain.WorkType
err := s.db.QueryRow("SELECT id, name, created_at FROM work_types WHERE id=?", id).Scan(&wt.ID, &wt.Name, &wt.CreatedAt)
if err != nil {
return nil, err
}
return &wt, nil
}
// Days
func (s *Store) GetOrCreateDay(userID int64, date string) (*domain.Day, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)",
userID, date,
); err != nil {
return nil, err
}
return s.GetDay(userID, date)
}
func (s *Store) GetDay(userID int64, date string) (*domain.Day, error) {
row := s.db.QueryRow(`
SELECT id, user_id, date, is_day_off, day_off_reason,
current_work_type_id, min_break_threshold, created_at, updated_at
FROM days WHERE user_id=? AND date=?
`, userID, date)
var d domain.Day
if err := row.Scan(
&d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason,
&d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}
return &d, nil
}
func (s *Store) UpdateDay(day *domain.Day) error {
_, err := s.db.Exec(
"UPDATE days SET is_day_off=?, day_off_reason=?, current_work_type_id=?, min_break_threshold=?, updated_at=unixepoch() WHERE id=?",
day.IsDayOff, day.DayOffReason, day.CurrentWorkTypeID, day.MinBreakThreshold, day.ID,
)
return err
}
func (s *Store) SetDayOff(userID int64, date string, reason string) error {
if _, err := s.GetOrCreateDay(userID, date); err != nil {
return err
}
_, err := s.db.Exec(
"UPDATE days SET is_day_off=1, day_off_reason=?, updated_at=unixepoch() WHERE user_id=? AND date=?",
reason, userID, date,
)
return err
}
func (s *Store) RemoveDayOff(userID int64, date string) error {
_, err := s.db.Exec(
"UPDATE days SET is_day_off=0, day_off_reason='', updated_at=unixepoch() WHERE user_id=? AND date=?",
userID, date,
)
return err
}
func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]domain.Day, error) {
rows, err := s.db.Query(`
SELECT id, user_id, date, is_day_off, day_off_reason,
current_work_type_id, min_break_threshold, created_at, updated_at
FROM days
WHERE user_id=? AND date >= ? AND date <= ?
ORDER BY date ASC
`, userID, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
var days []domain.Day
for rows.Next() {
var d domain.Day
if err := rows.Scan(
&d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason,
&d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}
days = append(days, d)
}
return days, rows.Err()
}
func (s *Store) GetDatesWithEvents(userID int64) ([]string, error) {
rows, err := s.db.Query(`
SELECT DISTINCT d.date
FROM days d
JOIN events e ON e.day_id = d.id
WHERE d.user_id = ?
ORDER BY d.date ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var dates []string
for rows.Next() {
var date string
if err := rows.Scan(&date); err != nil {
return nil, err
}
dates = append(dates, date)
}
return dates, rows.Err()
}
func (s *Store) GetDistinctEventDates(userID int64, startDate, endDate string) ([]string, error) {
rows, err := s.db.Query(
"SELECT DISTINCT d.date FROM days d JOIN events e ON e.day_id = d.id WHERE d.user_id=? AND d.date >= ? AND d.date <= ? ORDER BY d.date",
userID, startDate, endDate,
)
if err != nil {
return nil, err
}
defer rows.Close()
var dates []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err != nil {
return nil, err
}
dates = append(dates, d)
}
return dates, rows.Err()
}
// Events
func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error {
_, err := s.db.Exec(
"INSERT INTO events (user_id, day_id, event_type, work_type_id, occurred_at, note) VALUES (?, ?, ?, ?, ?, ?)",
userID, dayID, eventType, workTypeID, occurredAt, note,
)
return err
}
func (s *Store) UpdateEventWorkType(eventID, workTypeID int64) error {
_, err := s.db.Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID)
return err
}
func (s *Store) UpdateEventTime(eventID, occurredAt int64) error {
_, err := s.db.Exec("UPDATE events SET occurred_at=? WHERE id=?", occurredAt, eventID)
return err
}
func (s *Store) DeleteEvent(eventID int64) error {
_, err := s.db.Exec("DELETE FROM events WHERE id=?", eventID)
return err
}
func (s *Store) GetEventByID(eventID, userID int64) (*domain.Event, error) {
row := s.db.QueryRow(
"SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE id=? AND user_id=?",
eventID, userID,
)
var e domain.Event
var wt sql.NullInt64
if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
return nil, err
}
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
return &e, nil
}
func (s *Store) GetLastEvent(userID int64) (*domain.Event, error) {
row := s.db.QueryRow(`
SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
FROM events
WHERE user_id = ?
ORDER BY occurred_at DESC
LIMIT 1
`, userID)
var e domain.Event
var wt sql.NullInt64
if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, sql.ErrNoRows
}
return nil, err
}
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
return &e, nil
}
func (s *Store) EventsForDayByDate(userID int64, date string) ([]domain.Event, error) {
rows, err := s.db.Query(`
SELECT e.id, e.user_id, e.day_id, e.event_type, e.work_type_id, e.occurred_at, e.note, e.created_at
FROM events e
JOIN days d ON d.id = e.day_id
WHERE d.user_id = ? AND d.date = ?
ORDER BY e.occurred_at ASC
`, userID, date)
if err != nil {
return nil, err
}
defer rows.Close()
var events []domain.Event
for rows.Next() {
var e domain.Event
var wt sql.NullInt64
if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
return nil, err
}
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
events = append(events, e)
}
return events, rows.Err()
}
func (s *Store) EventsForDayByDayID(dayID int64) ([]domain.Event, error) {
rows, err := s.db.Query(`
SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
FROM events
WHERE day_id = ?
ORDER BY occurred_at ASC
`, dayID)
if err != nil {
return nil, err
}
defer rows.Close()
var events []domain.Event
for rows.Next() {
var e domain.Event
var wt sql.NullInt64
if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
return nil, err
}
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
events = append(events, e)
}
return events, rows.Err()
}
func (s *Store) SetEventNote(eventID int64, note string) error {
_, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID)
return err
}

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"
}
}
}

View File

@@ -1,16 +0,0 @@
{
"unknown_command": "Unknown command",
"clock_in": "Clock In",
"clock_out": "Clock Out",
"error": "Error",
"no_permission": "You do not have permission",
"remote_switched_on": "Remote mode enabled",
"remote_switched_off": "Remote mode disabled",
"already_clocked_in": "You are already clocked in",
"already_clocked_out": "You are already clocked out",
"break_started": "Break started",
"remote_enabled": "Remote mode enabled",
"remote_disabled": "Remote mode disabled",
"dayoff_set": "Today marked as day off",
"dayoff_removed": "Day off removed"
}

View File

@@ -1,16 +0,0 @@
{
"unknown_command": "دستور نامشخص",
"clock_in": "ورودی کاری",
"clock_out": "خروج کاری",
"error": "خطا",
"no_permission": "دسترسی ندارید",
"remote_switched_on": "وضعیت راه دور فعال شد",
"remote_switched_off": "وضعیت راه دور غیرفعال شد",
"already_clocked_in": "شما قبلاً ورود زده‌اید",
"already_clocked_out": "شما قبلاً خروج زده‌اید",
"break_started": "استراحت شروع شد",
"remote_enabled": "وضعیت راه دور فعال شد",
"remote_disabled": "وضعیت راه دور غیرفعال شد",
"dayoff_set": "امروز به عنوان تعطیل ثبت شد",
"dayoff_removed": "تعطیلی برداشته شد"
}

View File

@@ -1,67 +0,0 @@
package i18n
import (
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"sync"
)
// Translator handles translation loading and retrieval.
type Translator struct {
mu sync.RWMutex
messages map[string]map[string]string // lang -> key -> message
}
// NewTranslator loads JSON locale files en.json and fa.json from the i18n directory.
// defaultLang sets the default fallback language (e.g., "en").
func NewTranslator(defaultLang string) (*Translator, error) {
if defaultLang == "" {
defaultLang = "en"
}
t := &Translator{
messages: make(map[string]map[string]string),
}
// Load known locale files: en.json and fa.json located in the same package directory.
for _, lang := range []string{"en", "fa"} {
filePath := filepath.Join("i18n", lang+".json")
data, err := ioutil.ReadFile(filePath)
if err != nil {
return nil, err
}
var m map[string]string
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
t.messages[lang] = m
}
// Ensure default language exists.
if _, ok := t.messages[defaultLang]; !ok {
return nil, fmt.Errorf("default language %s not found", defaultLang)
}
return t, nil
}
// Get returns the translated string for a given key and language.
// Falls back to English if the key/language is missing.
func (t *Translator) Get(key, lang string) string {
if lang == "" {
lang = "en"
}
t.mu.RLock()
defer t.mu.RUnlock()
if msgs, ok := t.messages[lang]; ok {
if v, ok := msgs[key]; ok {
return v
}
}
// fallback to English messages
if msgs, ok := t.messages["en"]; ok {
if v, ok := msgs[key]; ok {
return v
}
}
// If not found, return the key itself.
return key
}