Compare commits
69 Commits
0a9175add4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
bddb920c82
|
|||
|
2c1c149b13
|
|||
|
6f7f5878bf
|
|||
|
7dbd9fda12
|
|||
|
d9e13cb51d
|
|||
|
59c35305c7
|
|||
|
5670b0455d
|
|||
|
0926323d18
|
|||
|
a96419f324
|
|||
|
d15ed46066
|
|||
|
3835b002a7
|
|||
|
3cd692be0f
|
|||
|
b822068f0a
|
|||
|
80de661f5b
|
|||
|
af23f80fbb
|
|||
|
5cd811e057
|
|||
|
4fcb694418
|
|||
|
85587d563d
|
|||
|
88ccc6aa68
|
|||
|
605c41ab88
|
|||
|
344c615666
|
|||
|
0d942c51db
|
|||
|
5f12e3838b
|
|||
|
e399c3ee88
|
|||
|
c9dd076235
|
|||
|
e539350030
|
|||
|
834c318271
|
|||
|
323c1bc010
|
|||
|
e7e4dc7bfe
|
|||
|
4165839477
|
|||
|
68c8518522
|
|||
|
8d919decb0
|
|||
|
4edff71bfd
|
|||
|
c959cb7a87
|
|||
|
af7a438174
|
|||
|
353056349f
|
|||
|
75da4648aa
|
|||
|
49c0e82bac
|
|||
|
fb2d0ef7d1
|
|||
|
a8b849f8fd
|
|||
|
d8599657f4
|
|||
|
f89f5607aa
|
|||
|
577aee91fb
|
|||
|
67eae5ffa3
|
|||
|
8fe43bff4d
|
|||
|
069bbb614e
|
|||
|
cafa139198
|
|||
|
69c5e20d8e
|
|||
|
07306cb0f0
|
|||
|
9c83ed98c6
|
|||
|
b6c7cf81c8
|
|||
|
b900710196
|
|||
|
9d123316e6
|
|||
|
6f6cc6f184
|
|||
|
2970b7d3fc
|
|||
|
8616ed0867
|
|||
|
7aada8d118
|
|||
|
b4fab29d45
|
|||
|
32446a99d1
|
|||
|
7da6f4cb49
|
|||
|
a92c72f46a
|
|||
|
5009bb130c
|
|||
|
4a5778afe5
|
|||
|
0be03f7078
|
|||
|
c4e8c8dc3a
|
|||
|
b0fcb7a6c3
|
|||
|
3aaf5b326d
|
|||
|
f6d6e994aa
|
|||
|
7d03a4d8fc
|
@@ -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=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
db.sqlite3
|
||||
.env
|
||||
.DS_Store
|
||||
opencode.json
|
||||
|
||||
39
AGENTS.md
39
AGENTS.md
@@ -1,39 +0,0 @@
|
||||
# Agents.md
|
||||
|
||||
## Current Task
|
||||
|
||||
Build a simple Telegram time‑tracking bot in Go.
|
||||
|
||||
## Context Summary
|
||||
|
||||
- **User requirements**: only clock‑in / clock‑out, auto‑infer breaks, onsite/remote flag (ask after first clock‑in), 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, on‑demand Excel download, Docker multi‑stage, CI/CD workflow.
|
||||
- **Implementation plan**: 11 phases (scaffold → Docker → CI), with edge‑case handling (multiple clock‑outs, missed clock‑out, midnight sessions, time‑zone).
|
||||
|
||||
## Edge Cases Covered
|
||||
|
||||
- Multiple clock‑outs → treat middle clock‑out as break start.
|
||||
- No clock‑out → blocked unless clock‑in first.
|
||||
- Midnight or overnight sessions → attributed to originating day.
|
||||
- Unpaired final event → indicates currently clocked‑in.
|
||||
- Remote work flag set after first clock‑in (default onsite).
|
||||
|
||||
## Edge Cases Checklist
|
||||
|
||||
- [ ] Clock‑in/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 clock‑in / clock‑out handlers with validation.
|
||||
5. Build event pairing & daily/weekly/monthly totals logic.
|
||||
6. Create monthly .xlsx template and generation.
|
||||
7. Implement day‑off handling and edit‑event UI.
|
||||
8. Add Dockerfile, docker‑compose, and Gitea CI workflow.
|
||||
9. Polish i18n, error handling, and edge‑case tests.
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,20 +1,24 @@
|
||||
FROM golang:1.26-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"]
|
||||
|
||||
24
Makefile
24
Makefile
@@ -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
203
README.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# WorkTime Bot
|
||||
|
||||
[](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
|
||||
```
|
||||
170
cmd/bot/main.go
170
cmd/bot/main.go
@@ -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 long‑polling
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
@@ -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
15
go.mod
@@ -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
20
go.sum
@@ -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=
|
||||
|
||||
@@ -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 day‑offs
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"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 clock‑in 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 errors.New(h.Translator.Get("already_clocked_in", "en"))
|
||||
}
|
||||
// Set default remote flag after first clock‑in.
|
||||
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 clock‑out event.
|
||||
func (h *Handler) handleClockOut() error {
|
||||
last, err := h.DB.GetLastEvent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if last == nil {
|
||||
return errors.New(h.Translator.Get("error", "en"))
|
||||
}
|
||||
switch last.EventType {
|
||||
case "in":
|
||||
return h.DB.CreateEvent("out", time.Now().Unix(), "")
|
||||
case "out":
|
||||
return errors.New(h.Translator.Get("already_clocked_out", "en"))
|
||||
default:
|
||||
return errors.New(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 human‑readable 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)
|
||||
}
|
||||
@@ -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.
|
||||
// Multi‑out 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 day‑off 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 day‑off 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"`
|
||||
}
|
||||
24
internal/domain/burnout.go
Normal file
24
internal/domain/burnout.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ComputeTrendFromAvgs is a pure version of the trend computation for testing.
|
||||
func ComputeTrendFromAvgs(recentAvg, olderAvg float64) (pts int, direction string) {
|
||||
if olderAvg < 1 {
|
||||
if recentAvg > 0 {
|
||||
return 15, "new"
|
||||
}
|
||||
return 5, "stable"
|
||||
}
|
||||
changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100)
|
||||
if recentAvg > olderAvg*1.2 {
|
||||
return 15, fmt.Sprintf("+%.0f%%", changePct)
|
||||
}
|
||||
if olderAvg > recentAvg*1.2 {
|
||||
return 0, fmt.Sprintf("%.0f%%", changePct)
|
||||
}
|
||||
return 5, "stable"
|
||||
}
|
||||
19
internal/domain/constants.go
Normal file
19
internal/domain/constants.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
DateLayout = "2006-01-02"
|
||||
TimeLayout = "15:04"
|
||||
SecondsPerHour = 3600
|
||||
SecondsPerDay = 86400
|
||||
DefaultBreakThreshold = 300
|
||||
MaxBreakThresholdMins = 480
|
||||
StreakBonusCap = 500
|
||||
XPCurveMultiplier = 50
|
||||
SalaryRoundFactor = 100
|
||||
MaxHourlyRate = 999999
|
||||
RateLimitInterval = 1 * time.Second
|
||||
MaxNoteLength = 500
|
||||
MaxUsernameLength = 64
|
||||
)
|
||||
326
internal/domain/dateutil.go
Normal file
326
internal/domain/dateutil.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package domain
|
||||
|
||||
import "fmt"
|
||||
|
||||
var GregMonthDays = []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
|
||||
var JalaliMonthDays = []int{31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29}
|
||||
|
||||
var GregMonthNames = []string{
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
}
|
||||
var JalaliMonthNames = []string{
|
||||
"Farvardin", "Ordibehesht", "Khordad", "Tir", "Mordad", "Shahrivar",
|
||||
"Mehr", "Aban", "Azar", "Dey", "Bahman", "Esfand",
|
||||
}
|
||||
var HijriMonthNames = []string{
|
||||
"Muharram", "Safar", "Rabi' I", "Rabi' II", "Jumada I", "Jumada II",
|
||||
"Rajab", "Sha'ban", "Ramadan", "Shawwal", "Dhu al-Qa'dah", "Dhu al-Hijjah",
|
||||
}
|
||||
|
||||
func IsGregorianLeap(year int) bool {
|
||||
return year%4 == 0 && (year%100 != 0 || year%400 == 0)
|
||||
}
|
||||
|
||||
func IsJalaliLeap(year int) bool {
|
||||
r := year % 33
|
||||
return r == 1 || r == 5 || r == 9 || r == 13 || r == 17 || r == 22 || r == 26 || r == 30
|
||||
}
|
||||
|
||||
func MonthLenGreg(year, month int) int {
|
||||
if month == 2 && IsGregorianLeap(year) {
|
||||
return 29
|
||||
}
|
||||
return GregMonthDays[month-1]
|
||||
}
|
||||
|
||||
func MonthLenJalali(year, month int) int {
|
||||
if month == 12 && IsJalaliLeap(year) {
|
||||
return 30
|
||||
}
|
||||
return JalaliMonthDays[month-1]
|
||||
}
|
||||
|
||||
func IsHijriLeap(year int) bool {
|
||||
switch year % 30 {
|
||||
case 2, 5, 7, 10, 13, 16, 18, 21, 24, 27, 29:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func MonthLenHijri(year, month int) int {
|
||||
if month == 12 && IsHijriLeap(year) {
|
||||
return 30
|
||||
}
|
||||
if month%2 == 1 {
|
||||
return 30
|
||||
}
|
||||
return 29
|
||||
}
|
||||
|
||||
const jalaliEpochJDN = 1948320
|
||||
|
||||
func GregorianToJDN(gy, gm, gd int) int {
|
||||
a := (14 - gm) / 12
|
||||
y := gy + 4800 - a
|
||||
m := gm + 12*a - 3
|
||||
return gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
|
||||
}
|
||||
|
||||
func JDNToGregorian(jdn int) (int, int, int) {
|
||||
f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38
|
||||
e := 4*f + 3
|
||||
g := (e % 1461) / 4
|
||||
h := 5*g + 2
|
||||
day := (h%153)/5 + 1
|
||||
month := ((h/153 + 2) % 12) + 1
|
||||
year := e/1461 - 4716 + (14-month)/12
|
||||
return year, month, day
|
||||
}
|
||||
|
||||
func GregorianToJalali(gy, gm, gd int) (int, int, int) {
|
||||
jdn := GregorianToJDN(gy, gm, gd)
|
||||
days := jdn - jalaliEpochJDN
|
||||
if days < 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
jy := 1
|
||||
for {
|
||||
yearDays := 365
|
||||
if IsJalaliLeap(jy) {
|
||||
yearDays = 366
|
||||
}
|
||||
if days < yearDays {
|
||||
break
|
||||
}
|
||||
days -= yearDays
|
||||
jy++
|
||||
}
|
||||
jm := 1
|
||||
for mm := 0; mm < 12; mm++ {
|
||||
md := MonthLenJalali(jy, mm+1)
|
||||
if days < md {
|
||||
break
|
||||
}
|
||||
days -= md
|
||||
jm++
|
||||
}
|
||||
jd := days + 1
|
||||
return jy, jm, jd
|
||||
}
|
||||
|
||||
func JalaliToGregorian(jy, jm, jd int) (int, int, int) {
|
||||
days := 0
|
||||
for y := 1; y < jy; y++ {
|
||||
if IsJalaliLeap(y) {
|
||||
days += 366
|
||||
} else {
|
||||
days += 365
|
||||
}
|
||||
}
|
||||
for m := 1; m < jm; m++ {
|
||||
days += MonthLenJalali(jy, m)
|
||||
}
|
||||
days += jd - 1
|
||||
jdn := jalaliEpochJDN + days
|
||||
return JDNToGregorian(jdn)
|
||||
}
|
||||
|
||||
func HijriToGregorian(hy, hm, hd int) (int, int, int) {
|
||||
days := (hy-1)*354 + (hy-1)*11/30
|
||||
for m := 1; m < hm; m++ {
|
||||
days += MonthLenHijri(hy, m)
|
||||
}
|
||||
days += hd - 1
|
||||
jdn := 1948440 + days
|
||||
f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38
|
||||
e := 4*f + 3
|
||||
g := (e % 1461) / 4
|
||||
h := 5*g + 2
|
||||
day := (h%153)/5 + 1
|
||||
month := ((h/153 + 2) % 12) + 1
|
||||
year := e/1461 - 4716 + (14-month)/12
|
||||
return year, month, day
|
||||
}
|
||||
|
||||
func GregorianToHijri(gy, gm, gd int) (int, int, int) {
|
||||
jdn := GregorianToJDN(gy, gm, gd)
|
||||
days := jdn - 1948440
|
||||
hy := 1
|
||||
for {
|
||||
yearDays := 354
|
||||
if IsHijriLeap(hy) {
|
||||
yearDays = 355
|
||||
}
|
||||
if days < yearDays {
|
||||
break
|
||||
}
|
||||
days -= yearDays
|
||||
hy++
|
||||
}
|
||||
hm := 1
|
||||
for mm := 1; mm <= 12; mm++ {
|
||||
md := MonthLenHijri(hy, mm)
|
||||
if days < md {
|
||||
break
|
||||
}
|
||||
days -= md
|
||||
hm++
|
||||
}
|
||||
hd := days + 1
|
||||
return hy, hm, hd
|
||||
}
|
||||
|
||||
func DayOfWeekGregorian(gy, gm, gd int) int {
|
||||
if gm < 3 {
|
||||
gm += 12
|
||||
gy--
|
||||
}
|
||||
return (gd + (13*(gm+1))/5 + gy + gy/4 - gy/100 + gy/400) % 7
|
||||
}
|
||||
|
||||
type CalDay struct {
|
||||
DayNum int
|
||||
Date string
|
||||
}
|
||||
|
||||
type CalMonth struct {
|
||||
Title string
|
||||
WeekDays []string
|
||||
Days []CalDay
|
||||
}
|
||||
|
||||
func BuildCalendarMonth(cal string, year, month int) CalMonth {
|
||||
var title string
|
||||
var weekDays []string
|
||||
var totalDays int
|
||||
|
||||
if cal == "jalali" {
|
||||
title = fmt.Sprintf("%s %d", JalaliMonthNames[month-1], year)
|
||||
weekDays = []string{"Sh", "Ye", "Do", "Se", "Ch", "Pa", "Jo"}
|
||||
totalDays = MonthLenJalali(year, month)
|
||||
gy, gm, gd := JalaliToGregorian(year, month, 1)
|
||||
dow := DayOfWeekGregorian(gy, gm, gd)
|
||||
startOffset := dow
|
||||
var days []CalDay
|
||||
for i := 0; i < startOffset; i++ {
|
||||
days = append(days, CalDay{DayNum: 0, Date: ""})
|
||||
}
|
||||
for d := 1; d <= totalDays; d++ {
|
||||
gy, gm, gd = JalaliToGregorian(year, month, d)
|
||||
days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)})
|
||||
}
|
||||
return CalMonth{Title: title, WeekDays: weekDays, Days: days}
|
||||
}
|
||||
|
||||
if cal == "hijri" {
|
||||
title = fmt.Sprintf("%s %d", HijriMonthNames[month-1], year)
|
||||
weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}
|
||||
totalDays = MonthLenHijri(year, month)
|
||||
gy, gm, gd := HijriToGregorian(year, month, 1)
|
||||
dow := DayOfWeekGregorian(gy, gm, gd)
|
||||
startOffset := (dow + 5) % 7
|
||||
var days []CalDay
|
||||
for i := 0; i < startOffset; i++ {
|
||||
days = append(days, CalDay{DayNum: 0, Date: ""})
|
||||
}
|
||||
for d := 1; d <= totalDays; d++ {
|
||||
gy, gm, gd = HijriToGregorian(year, month, d)
|
||||
days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)})
|
||||
}
|
||||
return CalMonth{Title: title, WeekDays: weekDays, Days: days}
|
||||
}
|
||||
|
||||
title = fmt.Sprintf("%s %d", GregMonthNames[month-1], year)
|
||||
weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}
|
||||
totalDays = MonthLenGreg(year, month)
|
||||
dow := DayOfWeekGregorian(year, month, 1)
|
||||
startOffset := (dow + 5) % 7
|
||||
var days []CalDay
|
||||
for i := 0; i < startOffset; i++ {
|
||||
days = append(days, CalDay{DayNum: 0, Date: ""})
|
||||
}
|
||||
for d := 1; d <= totalDays; d++ {
|
||||
days = append(days, CalDay{DayNum: d, Date: fmt.Sprintf("%04d-%02d-%02d", year, month, d)})
|
||||
}
|
||||
return CalMonth{Title: title, WeekDays: weekDays, Days: days}
|
||||
}
|
||||
|
||||
func ParseGregorianDateKey(key string) (int, int, int) {
|
||||
var y, m, d int
|
||||
fmt.Sscanf(key, "%04d-%02d-%02d", &y, &m, &d)
|
||||
return y, m, d
|
||||
}
|
||||
|
||||
func FormatDateForCalendar(date, calType string) string {
|
||||
y, m, d := ParseGregorianDateKey(date)
|
||||
switch calType {
|
||||
case "jalali":
|
||||
jy, jm, jd := GregorianToJalali(y, m, d)
|
||||
return fmt.Sprintf("%04d-%02d-%02d", jy, jm, jd)
|
||||
case "hijri":
|
||||
hy, hm, hd := GregorianToHijri(y, m, d)
|
||||
return fmt.Sprintf("%04d-%02d-%02d", hy, hm, hd)
|
||||
default:
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
func UserDateToGregorian(dateStr, cal string) string {
|
||||
y, m, d := ParseGregorianDateKey(dateStr)
|
||||
switch cal {
|
||||
case "jalali":
|
||||
y, m, d = JalaliToGregorian(y, m, d)
|
||||
case "hijri":
|
||||
y, m, d = HijriToGregorian(y, m, d)
|
||||
}
|
||||
return fmt.Sprintf("%04d-%02d-%02d", y, m, d)
|
||||
}
|
||||
|
||||
func MonthGregorianRange(cal string, year, month int) (start, end string) {
|
||||
switch cal {
|
||||
case "jalali":
|
||||
gy, gm, gd := JalaliToGregorian(year, month, 1)
|
||||
start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
|
||||
lastDay := MonthLenJalali(year, month)
|
||||
gy, gm, gd = JalaliToGregorian(year, month, lastDay)
|
||||
end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
|
||||
case "hijri":
|
||||
gy, gm, gd := HijriToGregorian(year, month, 1)
|
||||
start = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
|
||||
lastDay := MonthLenHijri(year, month)
|
||||
gy, gm, gd = HijriToGregorian(year, month, lastDay)
|
||||
end = fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd)
|
||||
default:
|
||||
start = fmt.Sprintf("%04d-%02d-%02d", year, month, 1)
|
||||
lastDay := MonthLenGreg(year, month)
|
||||
end = fmt.Sprintf("%04d-%02d-%02d", year, month, lastDay)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func FormatMonthTitle(cal string, year, month int) string {
|
||||
switch cal {
|
||||
case "jalali":
|
||||
return fmt.Sprintf("%s %d", JalaliMonthNames[month-1], year)
|
||||
case "hijri":
|
||||
return fmt.Sprintf("%s %d", HijriMonthNames[month-1], year)
|
||||
default:
|
||||
return fmt.Sprintf("%s %d", GregMonthNames[month-1], year)
|
||||
}
|
||||
}
|
||||
|
||||
func NavMonth(year, month int) (prevY, prevM, nextY, nextM int) {
|
||||
prevY, prevM = year, month-1
|
||||
if prevM < 1 {
|
||||
prevM = 12
|
||||
prevY--
|
||||
}
|
||||
nextY, nextM = year, month+1
|
||||
if nextM > 12 {
|
||||
nextM = 1
|
||||
nextY++
|
||||
}
|
||||
return
|
||||
}
|
||||
238
internal/domain/dateutil_test.go
Normal file
238
internal/domain/dateutil_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGregorianToJDN_RoundTrip(t *testing.T) {
|
||||
cases := []struct{ y, m, d int }{
|
||||
{2024, 1, 1},
|
||||
{2024, 12, 31},
|
||||
{1970, 1, 1},
|
||||
{1, 1, 1},
|
||||
{2024, 2, 29},
|
||||
{1900, 2, 28},
|
||||
{2000, 2, 29},
|
||||
}
|
||||
for _, c := range cases {
|
||||
jdn := GregorianToJDN(c.y, c.m, c.d)
|
||||
y, m, d := JDNToGregorian(jdn)
|
||||
if y != c.y || m != c.m || d != c.d {
|
||||
t.Errorf("round-trip %04d-%02d-%02d -> JDN %d -> %04d-%02d-%02d", c.y, c.m, c.d, jdn, y, m, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGregorianToJalali_RoundTrip(t *testing.T) {
|
||||
cases := []struct{ gy, gm, gd int }{
|
||||
{2024, 3, 20},
|
||||
{2024, 3, 21},
|
||||
{2024, 9, 22},
|
||||
{2025, 3, 20},
|
||||
{1979, 2, 11},
|
||||
{622, 3, 22},
|
||||
{2024, 12, 31},
|
||||
{2024, 6, 21},
|
||||
{2024, 1, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
jy, jm, jd := GregorianToJalali(c.gy, c.gm, c.gd)
|
||||
if jy == 0 {
|
||||
t.Errorf("GregorianToJalali(%d, %d, %d) returned 0", c.gy, c.gm, c.gd)
|
||||
continue
|
||||
}
|
||||
gy2, gm2, gd2 := JalaliToGregorian(jy, jm, jd)
|
||||
if gy2 != c.gy || gm2 != c.gm || gd2 != c.gd {
|
||||
t.Errorf("round-trip Gregorian %04d-%02d-%02d -> Jalali %04d-%02d-%02d -> Gregorian %04d-%02d-%02d",
|
||||
c.gy, c.gm, c.gd, jy, jm, jd, gy2, gm2, gd2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGregorianToHijri_RoundTrip(t *testing.T) {
|
||||
cases := []struct{ gy, gm, gd int }{
|
||||
{2024, 3, 21},
|
||||
{2024, 1, 1},
|
||||
{2024, 12, 31},
|
||||
{1979, 2, 11},
|
||||
{622, 7, 16},
|
||||
{2024, 7, 7},
|
||||
{2024, 6, 21},
|
||||
{2000, 1, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
hy, hm, hd := GregorianToHijri(c.gy, c.gm, c.gd)
|
||||
if hy == 0 {
|
||||
t.Errorf("GregorianToHijri(%d, %d, %d) returned 0", c.gy, c.gm, c.gd)
|
||||
continue
|
||||
}
|
||||
gy2, gm2, gd2 := HijriToGregorian(hy, hm, hd)
|
||||
diff := (gy2-c.gy)*365 + (gm2-c.gm)*30 + (gd2 - c.gd)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 1 {
|
||||
t.Errorf("round-trip off by %d days: Gregorian %04d-%02d-%02d -> Hijri %04d-%02d-%02d -> Gregorian %04d-%02d-%02d",
|
||||
diff, c.gy, c.gm, c.gd, hy, hm, hd, gy2, gm2, gd2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLeapYear(t *testing.T) {
|
||||
gregLeaps := map[int]bool{
|
||||
2000: true, 2020: true, 2024: true, 1900: false, 2100: false, 2023: false,
|
||||
}
|
||||
for y, want := range gregLeaps {
|
||||
if got := IsGregorianLeap(y); got != want {
|
||||
t.Errorf("IsGregorianLeap(%d) = %v, want %v", y, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
jalaliLeaps := map[int]bool{
|
||||
1: true, 5: true, 9: true, 13: true, 17: true, 22: true, 26: true, 30: true,
|
||||
2: false, 33: false, 1403: true,
|
||||
}
|
||||
for y, want := range jalaliLeaps {
|
||||
if got := IsJalaliLeap(y); got != want {
|
||||
t.Errorf("IsJalaliLeap(%d) = %v, want %v", y, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
hijriLeaps := map[int]bool{
|
||||
2: true, 5: true, 7: true, 10: true, 13: true, 16: true,
|
||||
18: true, 21: true, 24: true, 27: true, 29: true,
|
||||
1: false, 3: false, 30: false,
|
||||
}
|
||||
for y, want := range hijriLeaps {
|
||||
if got := IsHijriLeap(y); got != want {
|
||||
t.Errorf("IsHijriLeap(%d) = %v, want %v", y, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthLengths(t *testing.T) {
|
||||
if got := MonthLenGreg(2024, 2); got != 29 {
|
||||
t.Errorf("MonthLenGreg(2024, 2) = %d, want 29", got)
|
||||
}
|
||||
if got := MonthLenGreg(2023, 2); got != 28 {
|
||||
t.Errorf("MonthLenGreg(2023, 2) = %d, want 28", got)
|
||||
}
|
||||
if got := MonthLenGreg(2024, 1); got != 31 {
|
||||
t.Errorf("MonthLenGreg(2024, 1) = %d, want 31", got)
|
||||
}
|
||||
|
||||
if got := MonthLenJalali(1, 12); got != 30 {
|
||||
t.Errorf("MonthLenJalali(1, 12) = %d, want 30 (leap)", got)
|
||||
}
|
||||
if got := MonthLenJalali(2, 12); got != 29 {
|
||||
t.Errorf("MonthLenJalali(2, 12) = %d, want 29 (non-leap)", got)
|
||||
}
|
||||
if got := MonthLenJalali(1403, 1); got != 31 {
|
||||
t.Errorf("MonthLenJalali(1403, 1) = %d, want 31", got)
|
||||
}
|
||||
|
||||
if got := MonthLenHijri(1, 1); got != 30 {
|
||||
t.Errorf("MonthLenHijri(1, 1) = %d, want 30 (odd month)", got)
|
||||
}
|
||||
if got := MonthLenHijri(1, 2); got != 29 {
|
||||
t.Errorf("MonthLenHijri(1, 2) = %d, want 29 (even non-leap)", got)
|
||||
}
|
||||
if got := MonthLenHijri(2, 12); got != 30 {
|
||||
t.Errorf("MonthLenHijri(2, 12) = %d, want 30 (leap year, month 12)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGregorianDateKey(t *testing.T) {
|
||||
y, m, d := ParseGregorianDateKey("2024-03-21")
|
||||
if y != 2024 || m != 3 || d != 21 {
|
||||
t.Errorf("ParseGregorianDateKey got %d-%d-%d, want 2024-3-21", y, m, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDateForCalendar(t *testing.T) {
|
||||
if got := FormatDateForCalendar("2024-03-21", "gregorian"); got != "2024-03-21" {
|
||||
t.Errorf("FormatDateForCalendar gregorian: got %s", got)
|
||||
}
|
||||
if got := FormatDateForCalendar("2024-03-21", "jalali"); got != "1403-01-02" {
|
||||
t.Errorf("FormatDateForCalendar jalali: got %s, want 1403-01-02", got)
|
||||
}
|
||||
if got := FormatDateForCalendar("2024-03-21", "hijri"); got != "1445-09-11" {
|
||||
t.Errorf("FormatDateForCalendar hijri: got %s, want 1445-09-11", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDateToGregorian(t *testing.T) {
|
||||
if got := UserDateToGregorian("1403-01-02", "jalali"); got != "2024-03-21" {
|
||||
t.Errorf("UserDateToGregorian jalali: got %s, want 2024-03-21", got)
|
||||
}
|
||||
if got := UserDateToGregorian("1445-09-11", "hijri"); got != "2024-03-21" {
|
||||
t.Errorf("UserDateToGregorian hijri: got %s, want 2024-03-21", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthGregorianRange(t *testing.T) {
|
||||
start, end := MonthGregorianRange("gregorian", 2024, 1)
|
||||
if start != "2024-01-01" || end != "2024-01-31" {
|
||||
t.Errorf("gregorian range: got %s - %s", start, end)
|
||||
}
|
||||
|
||||
start, end = MonthGregorianRange("jalali", 1403, 1)
|
||||
if start != "2024-03-20" || end != "2024-04-19" {
|
||||
t.Errorf("jalali range: got %s - %s", start, end)
|
||||
}
|
||||
|
||||
start, end = MonthGregorianRange("hijri", 1445, 9)
|
||||
if start != "2024-03-11" || end != "2024-04-09" {
|
||||
t.Errorf("hijri range: got %s - %s", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarMonth(t *testing.T) {
|
||||
cm := BuildCalendarMonth("gregorian", 2024, 1)
|
||||
if cm.Title != "January 2024" {
|
||||
t.Errorf("title: got %q", cm.Title)
|
||||
}
|
||||
if len(cm.WeekDays) != 7 {
|
||||
t.Errorf("weekDays count: %d", len(cm.WeekDays))
|
||||
}
|
||||
if len(cm.Days) < 28 {
|
||||
t.Errorf("too few days: %d", len(cm.Days))
|
||||
}
|
||||
|
||||
cm2 := BuildCalendarMonth("jalali", 1403, 1)
|
||||
if cm2.Title != "Farvardin 1403" {
|
||||
t.Errorf("jalali title: got %q", cm2.Title)
|
||||
}
|
||||
if len(cm2.Days) < 31 {
|
||||
t.Errorf("too few jalali days: %d", len(cm2.Days))
|
||||
}
|
||||
|
||||
cm3 := BuildCalendarMonth("hijri", 1445, 9)
|
||||
if cm3.Title != "Ramadan 1445" {
|
||||
t.Errorf("hijri title: got %q", cm3.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMonthTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
cal string
|
||||
year, m int
|
||||
want string
|
||||
}{
|
||||
{"gregorian", 2026, 6, "June 2026"},
|
||||
{"gregorian", 2024, 1, "January 2024"},
|
||||
{"jalali", 1404, 1, "Farvardin 1404"},
|
||||
{"jalali", 1405, 12, "Esfand 1405"},
|
||||
{"hijri", 1447, 1, "Muharram 1447"},
|
||||
{"hijri", 1447, 12, "Dhu al-Hijjah 1447"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
got := FormatMonthTitle(tc.cal, tc.year, tc.m)
|
||||
if got != tc.want {
|
||||
t.Errorf("FormatMonthTitle(%q, %d, %d) = %q, want %q",
|
||||
tc.cal, tc.year, tc.m, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
76
internal/domain/rpg.go
Normal file
76
internal/domain/rpg.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// XpForLevel returns the total XP required to reach level n.
|
||||
func XpForLevel(n int) int64 {
|
||||
return int64(n) * int64(n+1) * XPCurveMultiplier
|
||||
}
|
||||
|
||||
// LevelForXP returns the level for a given total XP amount.
|
||||
func LevelForXP(xp int64) int {
|
||||
l := 1
|
||||
for XpForLevel(l) <= xp {
|
||||
l++
|
||||
}
|
||||
return l - 1
|
||||
}
|
||||
|
||||
// XpForWorkSeconds calculates XP earned for a given amount of work.
|
||||
func XpForWorkSeconds(sec int64) int64 {
|
||||
return sec
|
||||
}
|
||||
|
||||
// StreakBonus returns bonus XP for maintaining a streak.
|
||||
func StreakBonus(streak int) int64 {
|
||||
b := int64(streak) * XPCurveMultiplier
|
||||
if b > StreakBonusCap {
|
||||
b = StreakBonusCap
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ComputeStreakFromDates computes current and longest streak from a sorted list of date strings.
|
||||
func ComputeStreakFromDates(dates []string, today string) (current, longest int) {
|
||||
if len(dates) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
dateSet := make(map[string]struct{}, len(dates))
|
||||
for _, d := range dates {
|
||||
dateSet[d] = struct{}{}
|
||||
}
|
||||
|
||||
if _, ok := dateSet[today]; ok {
|
||||
current = 1
|
||||
for i := 1; ; i++ {
|
||||
t, _ := time.Parse(DateLayout, today)
|
||||
prev := t.AddDate(0, 0, -i).Format(DateLayout)
|
||||
if _, ok := dateSet[prev]; ok {
|
||||
current++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
longest = 0
|
||||
run := 1
|
||||
for i := 1; i < len(dates); i++ {
|
||||
prev, _ := time.Parse(DateLayout, dates[i-1])
|
||||
curr, _ := time.Parse(DateLayout, dates[i])
|
||||
if curr.Sub(prev).Hours() == 24 {
|
||||
run++
|
||||
} else {
|
||||
if run > longest {
|
||||
longest = run
|
||||
}
|
||||
run = 1
|
||||
}
|
||||
}
|
||||
if run > longest {
|
||||
longest = run
|
||||
}
|
||||
|
||||
return current, longest
|
||||
}
|
||||
371
internal/domain/rpg_test.go
Normal file
371
internal/domain/rpg_test.go
Normal 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
51
internal/domain/salary.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ComputeDailySalary calculates salary for a single day's work.
|
||||
func ComputeDailySalary(workSeconds int64, hourlyRate float64) float64 {
|
||||
hours := float64(workSeconds) / SecondsPerHour
|
||||
return math.Round(hours*hourlyRate*SalaryRoundFactor) / SalaryRoundFactor
|
||||
}
|
||||
|
||||
// CurrencySymbol extracts the first token as symbol, defaulting to "$".
|
||||
func CurrencySymbol(currency string) string {
|
||||
if parts := strings.Fields(currency); len(parts) >= 1 && parts[0] != "" {
|
||||
return parts[0]
|
||||
}
|
||||
return "$"
|
||||
}
|
||||
|
||||
// FormatSalary formats a salary estimate into a readable string.
|
||||
func (e SalaryEstimate) FormatSalary(currency string) string {
|
||||
sym := CurrencySymbol(currency)
|
||||
code := "USD"
|
||||
if parts := strings.Fields(currency); len(parts) >= 2 {
|
||||
code = parts[1]
|
||||
}
|
||||
return fmt.Sprintf("%s%.2f %s", sym, e.GrossEarning, code)
|
||||
}
|
||||
|
||||
// FormatSalaryShort returns just the symbol + amount.
|
||||
func (e SalaryEstimate) FormatSalaryShort(currency string) string {
|
||||
return fmt.Sprintf("%s%.2f", CurrencySymbol(currency), e.GrossEarning)
|
||||
}
|
||||
|
||||
// ParseCurrencyInput validates and normalizes currency input (symbol + code).
|
||||
func ParseCurrencyInput(s string) (string, string) {
|
||||
parts := strings.Fields(s)
|
||||
if len(parts) < 2 {
|
||||
return "", "Please provide both symbol and code.\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial"
|
||||
}
|
||||
for _, p := range parts[:2] {
|
||||
if len(p) > 10 || strings.ContainsFunc(p, unicode.IsControl) {
|
||||
return "", "Invalid currency. Use short printable strings like $ USD, T Toman, IRR Rial."
|
||||
}
|
||||
}
|
||||
return parts[0] + " " + parts[1], ""
|
||||
}
|
||||
38
internal/domain/sanitize.go
Normal file
38
internal/domain/sanitize.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// SanitizeNote cleans user-provided note text: trims whitespace, limits length,
|
||||
// and removes control characters (keeps newlines).
|
||||
func SanitizeNote(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > MaxNoteLength {
|
||||
s = s[:MaxNoteLength]
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\t' || r == ' ' {
|
||||
return r
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// SanitizeDisplayName cleans a display name (username) for safe rendering.
|
||||
func SanitizeDisplayName(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > MaxUsernameLength {
|
||||
s = s[:MaxUsernameLength]
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
68
internal/domain/totals.go
Normal file
68
internal/domain/totals.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package domain
|
||||
|
||||
import "sort"
|
||||
|
||||
// ComputeDailyTotals calculates the total work and break time from a list of events.
|
||||
func ComputeDailyTotals(events []Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals {
|
||||
if len(events) == 0 {
|
||||
return DailyTotals{}
|
||||
}
|
||||
|
||||
sorted := make([]Event, len(events))
|
||||
copy(sorted, events)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].OccurredAt < sorted[j].OccurredAt
|
||||
})
|
||||
|
||||
var totalWork, totalBreak int64
|
||||
state := StateIdle
|
||||
var workStart, breakStart int64
|
||||
|
||||
if dayStart > 0 && 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}
|
||||
}
|
||||
241
internal/domain/totals_test.go
Normal file
241
internal/domain/totals_test.go
Normal 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
188
internal/domain/types.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package domain
|
||||
|
||||
// User represents a bot user with identity, timezone, and calendar preferences.
|
||||
type User struct {
|
||||
ID int64
|
||||
ChatID int64
|
||||
Username string
|
||||
Timezone string
|
||||
IsActive bool
|
||||
Calendar string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// UserSettings holds all user-configurable preferences.
|
||||
type UserSettings struct {
|
||||
UserID int64
|
||||
ExportAccent string
|
||||
ReportEnabled bool
|
||||
ReportTime string
|
||||
LastReportDate string
|
||||
RPGEnabled bool
|
||||
LeagueOptIn bool
|
||||
Currency string
|
||||
HourlyRate float64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// RPGStats holds the user's RPG progression data.
|
||||
type RPGStats struct {
|
||||
UserID int64
|
||||
XP int64
|
||||
Level int
|
||||
CurrentStreak int
|
||||
LongestStreak int
|
||||
TotalWorkSeconds int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// Achievement is a predefined achievement definition.
|
||||
type Achievement struct {
|
||||
ID int64
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
}
|
||||
|
||||
// UserAchievement records when a user unlocked an achievement.
|
||||
type UserAchievement struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
AchievementID int64
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
UnlockedAt int64
|
||||
}
|
||||
|
||||
// DailyXP records XP earned per day per user.
|
||||
type DailyXP struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Date string
|
||||
XPEarned int64
|
||||
WorkSeconds int64
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day.
|
||||
type WorkType struct {
|
||||
ID int64
|
||||
Name string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// Day represents a single day entry for a user, including day-off status and work type.
|
||||
type Day struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Date string
|
||||
IsDayOff bool
|
||||
DayOffReason string
|
||||
CurrentWorkTypeID int64
|
||||
MinBreakThreshold int64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// Event represents a single timestamped event ("in" or "out") for a user on a given day.
|
||||
type Event struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
DayID int64
|
||||
EventType string
|
||||
WorkTypeID *int64
|
||||
OccurredAt int64
|
||||
Note string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// LeagueEntry holds a user's league ranking data.
|
||||
type LeagueEntry struct {
|
||||
UserID int64
|
||||
Username string
|
||||
XP int64
|
||||
Level int
|
||||
Streak int
|
||||
TotalHours float64
|
||||
}
|
||||
|
||||
// SalaryEstimate holds a salary calculation result.
|
||||
type SalaryEstimate struct {
|
||||
Currency string
|
||||
HourlyRate float64
|
||||
WorkSeconds int64
|
||||
WorkHours float64
|
||||
GrossEarning float64
|
||||
WorkDays int
|
||||
}
|
||||
|
||||
// BurnoutLevel represents the severity of burnout indicators.
|
||||
type BurnoutLevel int
|
||||
|
||||
const (
|
||||
BurnoutHealthy BurnoutLevel = 0
|
||||
BurnoutMild BurnoutLevel = 1
|
||||
BurnoutModerate BurnoutLevel = 2
|
||||
BurnoutHigh BurnoutLevel = 3
|
||||
)
|
||||
|
||||
func (b BurnoutLevel) String() string {
|
||||
switch b {
|
||||
case BurnoutHealthy:
|
||||
return "Healthy"
|
||||
case BurnoutMild:
|
||||
return "Mild concern"
|
||||
case BurnoutModerate:
|
||||
return "Moderate concern"
|
||||
case BurnoutHigh:
|
||||
return "High concern"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// BurnoutResult holds the full burnout assessment.
|
||||
type BurnoutResult struct {
|
||||
Score int
|
||||
Level BurnoutLevel
|
||||
ConsecutivePts int
|
||||
LongHoursPts int
|
||||
BreakRatioPts int
|
||||
WeekendPts int
|
||||
TrendPts int
|
||||
LateNightPts int
|
||||
ConsecutiveDays int
|
||||
WorkSeconds int64
|
||||
BreakSeconds int64
|
||||
TrendDirection string
|
||||
}
|
||||
|
||||
// DailyTotals holds the computed total work and break seconds for a single day.
|
||||
type DailyTotals struct {
|
||||
TotalSeconds int64
|
||||
BreakSeconds int64
|
||||
}
|
||||
|
||||
// State represents the current tracking state during daily totals computation.
|
||||
type State int
|
||||
|
||||
const (
|
||||
StateIdle State = iota
|
||||
StateWorking
|
||||
StateOnBreak
|
||||
)
|
||||
|
||||
// LeagueSortOption represents a valid league sort column.
|
||||
type LeagueSortOption string
|
||||
|
||||
const (
|
||||
LeagueSortXP LeagueSortOption = "xp"
|
||||
LeagueSortLevel LeagueSortOption = "level"
|
||||
LeagueSortStreak LeagueSortOption = "streak"
|
||||
LeagueSortHours LeagueSortOption = "hours"
|
||||
)
|
||||
207
internal/handler/burnout.go
Normal file
207
internal/handler/burnout.go
Normal 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
|
||||
}
|
||||
542
internal/handler/calendar.go
Normal file
542
internal/handler/calendar.go
Normal 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
222
internal/handler/clock.go
Normal 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
531
internal/handler/export.go
Normal 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
464
internal/handler/handler.go
Normal 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)
|
||||
}
|
||||
70
internal/handler/handler_test.go
Normal file
70
internal/handler/handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
internal/handler/keyboard.go
Normal file
111
internal/handler/keyboard.go
Normal 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
111
internal/handler/league.go
Normal 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
91
internal/handler/note.go
Normal 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
169
internal/handler/report.go
Normal 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
402
internal/handler/rpg.go
Normal 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")
|
||||
}
|
||||
}
|
||||
95
internal/handler/salary.go
Normal file
95
internal/handler/salary.go
Normal 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,
|
||||
}
|
||||
}
|
||||
828
internal/handler/settings.go
Normal file
828
internal/handler/settings.go
Normal 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
120
internal/handler/summary.go
Normal 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()
|
||||
}
|
||||
75
internal/handler/worktype.go
Normal file
75
internal/handler/worktype.go
Normal 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)
|
||||
}
|
||||
62
internal/repo/interface.go
Normal file
62
internal/repo/interface.go
Normal 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
221
internal/repo/migrations.go
Normal 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, ¬null, &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, ¬e, &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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
59
internal/repo/migrations/001_init.sql
Normal file
59
internal/repo/migrations/001_init.sql
Normal 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;
|
||||
101
internal/repo/migrations/002_features.sql
Normal file
101
internal/repo/migrations/002_features.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
|
||||
export_accent TEXT NOT NULL DEFAULT 'ocean'
|
||||
CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')),
|
||||
report_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
report_time TEXT NOT NULL DEFAULT '23:00',
|
||||
last_report_date TEXT NOT NULL DEFAULT '',
|
||||
rpg_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
league_opt_in INTEGER NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT '$ USD',
|
||||
hourly_rate REAL NOT NULL DEFAULT 0.0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_settings (user_id)
|
||||
SELECT id FROM users;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_rpg (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
|
||||
xp INTEGER NOT NULL DEFAULT 0,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
current_streak INTEGER NOT NULL DEFAULT 0,
|
||||
longest_streak INTEGER NOT NULL DEFAULT 0,
|
||||
total_work_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_rpg (user_id)
|
||||
SELECT id FROM users;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_xp (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
date TEXT NOT NULL,
|
||||
xp_earned INTEGER NOT NULL DEFAULT 0,
|
||||
work_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE(user_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO achievements (code, name, description) VALUES
|
||||
('first_clockin', 'First Steps', 'Clock in for the first time'),
|
||||
('early_bird', 'Early Bird', 'Clock in before 7 AM'),
|
||||
('night_owl', 'Night Owl', 'Clock out after 10 PM'),
|
||||
('iron_streak_5', 'Iron Streak', 'Work 5 consecutive days'),
|
||||
('iron_streak_10', 'Iron Streak II', 'Work 10 consecutive days'),
|
||||
('iron_streak_30', 'Iron Streak III', 'Work 30 consecutive days'),
|
||||
('remote_veteran', 'Remote Veteran', 'Log 100 hours of remote work'),
|
||||
('onsite_master', 'Onsite Master', 'Log 100 hours of onsite work'),
|
||||
('consistency_master', 'Consistency Master', 'Work at least 6 hours daily for a week'),
|
||||
('perfect_week', 'Perfect Week', 'No days off for a full week'),
|
||||
('overtime_hero', 'Overtime Hero', 'Work more than 10 hours in a day'),
|
||||
('level_5', 'Getting Started', 'Reach level 5'),
|
||||
('level_10', 'Dedicated', 'Reach level 10'),
|
||||
('level_25', 'Veteran', 'Reach level 25'),
|
||||
('level_50', 'Legend', 'Reach level 50'),
|
||||
('weekend_warrior', 'Weekend Warrior', 'Work on a weekend day'),
|
||||
('hundred_hours', 'Century', 'Log 100 total work hours'),
|
||||
('five_hundred_hours', 'Iron Will', 'Log 500 total work hours'),
|
||||
('salary_setter', 'Salary Tracker', 'Configure your hourly rate'),
|
||||
('rpg_pioneer', 'RPG Pioneer', 'Enable the RPG system');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
achievement_id INTEGER NOT NULL REFERENCES achievements(id),
|
||||
unlocked_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE(user_id, achievement_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_xp_user_date ON daily_xp(user_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_achievements;
|
||||
DROP TABLE IF EXISTS achievements;
|
||||
DROP TABLE IF EXISTS daily_xp;
|
||||
DROP TABLE IF EXISTS user_rpg;
|
||||
DROP TABLE IF EXISTS user_settings;
|
||||
CREATE TABLE IF NOT EXISTS users_old AS SELECT * FROM users;
|
||||
DROP TABLE IF EXISTS users;
|
||||
-- Note: restoring users table without the username column requires
|
||||
-- recreating it from the backup. In practice this migration is safe
|
||||
-- and the down path preserves data via the backup table.
|
||||
ALTER TABLE users_old RENAME TO users;
|
||||
696
internal/repo/store.go
Normal file
696
internal/repo/store.go
Normal 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
593
internal/repo/store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
146
opencode.json
146
opencode.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "تعطیلی برداشته شد"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user