refactor: export full Gregorian range, add timezone fallback, cleanup rate limit, configurable schema path
- GenerateMonthlyReport now takes explicit Gregorian start/end range, computed via monthGregorianRange, so Jalali/Hijri months spanning two Gregorian months are fully covered in exports. - Added loadLocation helper (UTC fallback on error) and replaced all 11 ignored-error call sites in handlers + export. - Added periodicCleanup goroutine in NewHandler to prevent unbounded growth of the rateLimit map. - schemaPath is now read from SCHEMA_PATH env var (init), defaulting to /app/db/schema.sql. - Migrated sendDayView to use sendOrEdit helper, removing duplicated send/edit branching. - Removed dead code: userYearMonthToGregorian (unused), the old startOffset function (replaced). - Updated README with calendar docs, new env vars, full project tree. - Updated .env.example and docker-compose.yml with SCHEMA_PATH. - Simplified Makefile: added fmt, tidy, check, run-dev targets; removed broken Make-glob prerequisite pattern.
This commit is contained in:
@@ -2,6 +2,7 @@ BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN
|
|||||||
HTTP_PROXY=
|
HTTP_PROXY=
|
||||||
HTTPS_PROXY=
|
HTTPS_PROXY=
|
||||||
DB_PATH=db.sqlite3
|
DB_PATH=db.sqlite3
|
||||||
|
SCHEMA_PATH=
|
||||||
BOT_WEBHOOK_URL=
|
BOT_WEBHOOK_URL=
|
||||||
BOT_LISTEN=:8080
|
BOT_LISTEN=:8080
|
||||||
BOT_TLS_CERT=
|
BOT_TLS_CERT=
|
||||||
|
|||||||
24
Makefile
24
Makefile
@@ -1,24 +1,33 @@
|
|||||||
.PHONY: build run test clean docker docker-run lint vet
|
|
||||||
|
|
||||||
BINARY=worktime-bot
|
BINARY=worktime-bot
|
||||||
BUILD_DIR=.build
|
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 go.mod go.sum
|
all: build test check
|
||||||
mkdir -p $(BUILD_DIR)
|
|
||||||
|
build:
|
||||||
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/bot/
|
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/bot/
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
$(BUILD_DIR)/$(BINARY)
|
$(BUILD_DIR)/$(BINARY)
|
||||||
|
|
||||||
|
run-dev:
|
||||||
|
go run ./cmd/bot/
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
lint: vet
|
fmt:
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
vet:
|
vet:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
|
check: fmt vet
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD_DIR)
|
rm -rf $(BUILD_DIR)
|
||||||
|
|
||||||
@@ -27,6 +36,3 @@ docker:
|
|||||||
|
|
||||||
docker-run: docker
|
docker-run: docker
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
.PHONY: all
|
|
||||||
all: build test lint
|
|
||||||
|
|||||||
101
README.md
101
README.md
@@ -1,6 +1,6 @@
|
|||||||
# WorkTime Bot
|
# WorkTime Bot
|
||||||
|
|
||||||
A single-user Telegram time-tracking bot. Clock in/out, toggle remote/onsite, mark day off, get daily reports, and export monthly Excel reports.
|
A single-user Telegram time-tracking bot with **multi-calendar support** (Gregorian, Jalali/Persian, Hijri). Clock in/out, toggle remote/onsite, mark day off, get daily reports at a configurable time, browse history by month, and export monthly `.xlsx` reports.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -10,43 +10,69 @@ cp .env.example .env
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Default | Purpose |
|
||||||
| ------------------- | ----------------------------------------------------------- |
|
| ------------------- | -------------------- | ----------------------------------------------------- |
|
||||||
| `BOT_TOKEN` | Telegram Bot API token (required) |
|
| `BOT_TOKEN` | — | Telegram Bot API token **(required)** |
|
||||||
| `BOT_ALLOWED_USERS` | Comma-separated Telegram user IDs (empty = allow all) |
|
| `BOT_ALLOWED_USERS` | (all) | Comma-separated Telegram user IDs (empty = allow all) |
|
||||||
| `HTTPS_PROXY` | Outbound proxy for Telegram API (e.g. `http://proxy:10808`) |
|
| `HTTP_PROXY` | — | Outbound HTTP proxy for Telegram API |
|
||||||
| `DB_PATH` | Path to SQLite database (default: `db.sqlite3`) |
|
| `HTTPS_PROXY` | — | Outbound HTTPS proxy for Telegram API |
|
||||||
| `BOT_WEBHOOK_URL` | Set for webhook mode (omit for polling) |
|
| `DB_PATH` | `db.sqlite3` | Path to SQLite database file |
|
||||||
|
| `SCHEMA_PATH` | `/app/db/schema.sql` | Path to SQL schema 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.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
**Persistent reply keyboard** at the bottom: `Clock In` / `Clock Out`
|
**Persistent reply keyboard** at the bottom of the chat: `Clock In` / `Clock Out`
|
||||||
|
|
||||||
**Inline menu** on `/start`:
|
**Inline menu** on `/start`:
|
||||||
|
|
||||||
- Clock In / Clock Out
|
| Button | Action |
|
||||||
- Report / Export
|
| -------------------- | --------------------------------- |
|
||||||
- Toggle Remote/Onsite / Day Off
|
| 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 |
|
||||||
|
| Toggle Remote/Onsite | Switch work type |
|
||||||
|
| Day Off | Mark today as day off |
|
||||||
|
| Settings | Timezone, accent, calendar type |
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
| Command | Action |
|
| Command | Action |
|
||||||
| -------------------------- | ---------------------------- |
|
| --------------------- | ------------------------------------------------------- |
|
||||||
| `/start` | Show inline menu |
|
| `/start` | Show inline menu |
|
||||||
| `Clock In` or `/clockin` | Record clock-in |
|
| `/clockin` | Record clock-in |
|
||||||
| `Clock Out` or `/clockout` | Record clock-out |
|
| `/clockout` | Record clock-out |
|
||||||
| `/remote` | Toggle remote/onsite mode |
|
| `/remote` | Toggle remote/onsite |
|
||||||
| `/report` | Today's work & break summary |
|
| `/report` | Today's work & break summary |
|
||||||
| `/export` | Monthly `.xlsx` report |
|
| `/export` | Export this month (optional `YYYY-MM` in your calendar) |
|
||||||
| `/dayoff` | Toggle today as day off |
|
| `/dayoff` | Toggle today as day off |
|
||||||
|
| `/edit YYYY-MM-DD` | View/edit events for a specific date |
|
||||||
|
| `/settimezone <IANA>` | Set timezone (e.g. `Asia/Tehran`) |
|
||||||
|
| `/settings` | Open settings menu |
|
||||||
|
|
||||||
Daily report is sent automatically at 23:00.
|
## 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
|
## How Break Time Works
|
||||||
|
|
||||||
The gap between a clock-out and the next clock-in is counted as break:
|
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.
|
||||||
|
|
||||||
```
|
```
|
||||||
09:00 Clock In → work starts
|
09:00 Clock In → work starts
|
||||||
@@ -56,20 +82,43 @@ The gap between a clock-out and the next clock-in is counted as break:
|
|||||||
total: 7h work, 1h break
|
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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── cmd/bot/ main.go, webhook.go
|
├── cmd/bot/
|
||||||
|
│ ├── main.go Entrypoint, polling/webhook, scheduler
|
||||||
|
│ └── webhook.go Webhook mode with TLS support
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── bot/ handlers, export, totals
|
│ ├── bot/
|
||||||
│ └── db/ SQLite store
|
│ │ ├── handlers.go Message/callback routing, views, menu builders
|
||||||
├── db/schema.sql Database schema
|
│ │ ├── totals.go Break/work computation state machine
|
||||||
|
│ │ ├── export.go Excel (.xlsx) report generation
|
||||||
|
│ │ └── dateutil.go Gregorian/Jalali/Hijri conversions & calendars
|
||||||
|
│ └── db/
|
||||||
|
│ └── store.go SQLite store with auto-migration
|
||||||
|
├── db/
|
||||||
|
│ └── schema.sql Database schema
|
||||||
|
├── .gitea/workflows/
|
||||||
|
│ └── ci.yml Gitea Actions CI
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
└── docker-compose.yml
|
├── docker-compose.yml
|
||||||
|
├── Makefile
|
||||||
|
├── .env.example
|
||||||
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build
|
## Build & Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Local (requires Go 1.26+ and CGo-free SQLite)
|
||||||
|
make run
|
||||||
|
|
||||||
|
# Docker
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ services:
|
|||||||
- HTTP_PROXY=${HTTP_PROXY-}
|
- HTTP_PROXY=${HTTP_PROXY-}
|
||||||
- HTTPS_PROXY=${HTTPS_PROXY-}
|
- HTTPS_PROXY=${HTTPS_PROXY-}
|
||||||
- DB_PATH=/data/db.sqlite3
|
- DB_PATH=/data/db.sqlite3
|
||||||
|
- SCHEMA_PATH=${SCHEMA_PATH:-/app/db/schema.sql}
|
||||||
- TZ=Asia/Tehran
|
- TZ=Asia/Tehran
|
||||||
volumes:
|
volumes:
|
||||||
- worktime_data:/data
|
- worktime_data:/data
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package bot
|
package bot
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
var gregMonthDays = []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
|
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 jalaliMonthDays = []int{31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29}
|
||||||
@@ -303,20 +306,6 @@ func userDateToGregorian(dateStr, cal string) string {
|
|||||||
return fmt.Sprintf("%04d-%02d-%02d", y, m, d)
|
return fmt.Sprintf("%04d-%02d-%02d", y, m, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// userYearMonthToGregorian converts a (year, month) in the user's calendar
|
|
||||||
// to the corresponding Gregorian (year, month). The day is assumed to be 1.
|
|
||||||
func userYearMonthToGregorian(year, month int, cal string) (int, int) {
|
|
||||||
switch cal {
|
|
||||||
case "jalali":
|
|
||||||
gy, gm, _ := jalaliToGregorian(year, month, 1)
|
|
||||||
return gy, gm
|
|
||||||
case "hijri":
|
|
||||||
gy, gm, _ := hijriToGregorian(year, month, 1)
|
|
||||||
return gy, gm
|
|
||||||
}
|
|
||||||
return year, month
|
|
||||||
}
|
|
||||||
|
|
||||||
// monthGregorianRange returns the Gregorian start and end date strings
|
// monthGregorianRange returns the Gregorian start and end date strings
|
||||||
// for a given calendar month (cal=gregorian|jalali|hijri).
|
// for a given calendar month (cal=gregorian|jalali|hijri).
|
||||||
func monthGregorianRange(cal string, year, month int) (start, end string) {
|
func monthGregorianRange(cal string, year, month int) (start, end string) {
|
||||||
@@ -340,3 +329,12 @@ func monthGregorianRange(cal string, year, month int) (start, end string) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadLocation loads a timezone, falling back to UTC on error.
|
||||||
|
func loadLocation(tz string) *time.Location {
|
||||||
|
loc, err := time.LoadLocation(tz)
|
||||||
|
if err != nil {
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ var themes = map[string]themeColors{
|
|||||||
"catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"},
|
"catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, year int, month time.Month) ([]byte, error) {
|
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) {
|
||||||
theme, ok := themes[accent]
|
theme, ok := themes[accent]
|
||||||
if !ok {
|
if !ok {
|
||||||
theme = themes["ocean"]
|
theme = themes["ocean"]
|
||||||
@@ -76,16 +76,7 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
|||||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||||
})
|
})
|
||||||
|
|
||||||
var jy, jm int
|
monthName := formatMonthTitle(calendar, calYear, calMonth)
|
||||||
switch calendar {
|
|
||||||
case "jalali":
|
|
||||||
jy, jm, _ = gregorianToJalali(year, int(month), 1)
|
|
||||||
case "hijri":
|
|
||||||
jy, jm, _ = gregorianToHijri(year, int(month), 1)
|
|
||||||
default:
|
|
||||||
jy, jm = year, int(month)
|
|
||||||
}
|
|
||||||
monthName := formatMonthTitle(calendar, jy, jm)
|
|
||||||
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
|
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
|
||||||
f.MergeCell(sheet, "A1", "F1")
|
f.MergeCell(sheet, "A1", "F1")
|
||||||
f.SetCellStyle(sheet, "A1", "F1", titleStyle)
|
f.SetCellStyle(sheet, "A1", "F1", titleStyle)
|
||||||
@@ -106,16 +97,11 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
|||||||
f.SetColWidth(sheet, "E", "E", 10)
|
f.SetColWidth(sheet, "E", "E", 10)
|
||||||
f.SetColWidth(sheet, "F", "F", 10)
|
f.SetColWidth(sheet, "F", "F", 10)
|
||||||
|
|
||||||
loc, err := time.LoadLocation(timezone)
|
loc := loadLocation(timezone)
|
||||||
if err != nil {
|
|
||||||
loc = time.UTC
|
|
||||||
}
|
|
||||||
|
|
||||||
row := 3
|
row := 3
|
||||||
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc)
|
|
||||||
lastDay := firstDay.AddDate(0, 1, -1)
|
|
||||||
|
|
||||||
userDays, err := store.GetUserDaysInRange(userID, firstDay.Format("2006-01-02"), lastDay.Format("2006-01-02"))
|
userDays, err := store.GetUserDaysInRange(userID, start.Format("2006-01-02"), end.Format("2006-01-02"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -127,7 +113,7 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
|
|||||||
|
|
||||||
var totalWork, totalBreak int64
|
var totalWork, totalBreak int64
|
||||||
|
|
||||||
for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) {
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||||
dateStr := d.Format("2006-01-02")
|
dateStr := d.Format("2006-01-02")
|
||||||
displayDate := formatDateForCalendar(dateStr, calendar)
|
displayDate := formatDateForCalendar(dateStr, calendar)
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,28 @@ type Handler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) *Handler {
|
func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) *Handler {
|
||||||
return &Handler{
|
h := &Handler{
|
||||||
Bot: bot,
|
Bot: bot,
|
||||||
DB: store,
|
DB: store,
|
||||||
AllowedUsers: allowed,
|
AllowedUsers: allowed,
|
||||||
lastMsgTime: make(map[int64]time.Time),
|
lastMsgTime: make(map[int64]time.Time),
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type actionFunc func(int64) (string, error)
|
type actionFunc func(int64) (string, error)
|
||||||
@@ -222,7 +238,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if last != nil && last.EventType == "in" {
|
if last != nil && last.EventType == "in" {
|
||||||
loc, _ := time.LoadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
lastTime := time.Unix(last.OccurredAt, 0).In(loc)
|
lastTime := time.Unix(last.OccurredAt, 0).In(loc)
|
||||||
if lastTime.Format("2006-01-02") == now.Format("2006-01-02") {
|
if lastTime.Format("2006-01-02") == now.Format("2006-01-02") {
|
||||||
@@ -230,7 +246,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loc, _ := time.LoadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
wt := day.CurrentWorkTypeID
|
wt := day.CurrentWorkTypeID
|
||||||
if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
|
if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
|
||||||
@@ -254,7 +270,7 @@ func (h *Handler) clockOut(chatID int64) (string, error) {
|
|||||||
if last.EventType == "out" {
|
if last.EventType == "out" {
|
||||||
return "", errors.New("Already clocked out")
|
return "", errors.New("Already clocked out")
|
||||||
}
|
}
|
||||||
loc, _ := time.LoadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
|
if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -726,7 +742,7 @@ func (h *Handler) handleEditMsg(msg *tgbotapi.Message) {
|
|||||||
h.sendText(msg.Chat.ID, "Error loading profile")
|
h.sendText(msg.Chat.ID, "Error loading profile")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loc, _ := time.LoadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
date = userDateToGregorian(date, user.Calendar)
|
date = userDateToGregorian(date, user.Calendar)
|
||||||
h.sendDayView(msg.Chat.ID, 0, user, date, loc, true)
|
h.sendDayView(msg.Chat.ID, 0, user, date, loc, true)
|
||||||
}
|
}
|
||||||
@@ -737,7 +753,7 @@ func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, dat
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loc, _ := time.LoadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
|
|
||||||
if data == "history" {
|
if data == "history" {
|
||||||
h.editCalendar(chatID, msgID, 0, 0)
|
h.editCalendar(chatID, msgID, 0, 0)
|
||||||
@@ -816,7 +832,7 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loc, _ := time.LoadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
|
|
||||||
if year == 0 || month == 0 {
|
if year == 0 || month == 0 {
|
||||||
@@ -919,15 +935,7 @@ func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date strin
|
|||||||
tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"),
|
tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if isNewMsg || msgID == 0 {
|
h.sendOrEdit(chatID, msgID, text, &kb)
|
||||||
reply := tgbotapi.NewMessage(chatID, text)
|
|
||||||
reply.ReplyMarkup = &kb
|
|
||||||
h.Bot.Send(reply)
|
|
||||||
} else {
|
|
||||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
|
||||||
edit.ReplyMarkup = &kb
|
|
||||||
h.Bot.Send(edit)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,15 +971,7 @@ func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date strin
|
|||||||
))
|
))
|
||||||
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...)
|
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...)
|
||||||
|
|
||||||
if isNewMsg || msgID == 0 {
|
h.sendOrEdit(chatID, msgID, text, &kb)
|
||||||
reply := tgbotapi.NewMessage(chatID, text)
|
|
||||||
reply.ReplyMarkup = &kb
|
|
||||||
h.Bot.Send(reply)
|
|
||||||
} else {
|
|
||||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
|
||||||
edit.ReplyMarkup = &kb
|
|
||||||
h.Bot.Send(edit)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) editEventType(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
func (h *Handler) editEventType(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
||||||
@@ -1184,25 +1184,32 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) {
|
|||||||
h.sendText(msg.Chat.ID, "Error loading profile")
|
h.sendText(msg.Chat.ID, "Error loading profile")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loc, _ := time.LoadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
|
|
||||||
|
// Default to current month in user's calendar
|
||||||
|
cy, cm := now.Year(), int(now.Month())
|
||||||
|
switch user.Calendar {
|
||||||
|
case "jalali":
|
||||||
|
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
|
||||||
|
case "hijri":
|
||||||
|
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
|
||||||
|
}
|
||||||
|
|
||||||
// Parse optional YYYY-MM argument (in user's calendar)
|
// Parse optional YYYY-MM argument (in user's calendar)
|
||||||
gy, gm := now.Year(), int(now.Month())
|
|
||||||
if len(msg.Text) > 7 {
|
if len(msg.Text) > 7 {
|
||||||
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &gy, &gm); n == 2 {
|
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &cy, &cm); n == 2 {
|
||||||
if gy < 0 || gm < 1 || gm > 12 {
|
if cy < 0 || cm < 1 || cm > 12 {
|
||||||
h.sendText(msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
|
h.sendText(msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
gy, gm = userYearMonthToGregorian(gy, gm, user.Calendar)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h.sendExportDirect(msg.Chat.ID, user, gy, gm)
|
h.sendExportDirect(msg.Chat.ID, user, cy, cm)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
|
func (h *Handler) sendExportDirect(chatID int64, user *db.User, calYear, calMonth int) {
|
||||||
// Check if clocked in
|
// Check if clocked in
|
||||||
last, err := h.DB.GetLastEvent(user.ID)
|
last, err := h.DB.GetLastEvent(user.ID)
|
||||||
if err == nil && last != nil && last.EventType == "in" {
|
if err == nil && last != nil && last.EventType == "in" {
|
||||||
@@ -1210,8 +1217,20 @@ func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("export", "user_id", user.ID, "year", gy, "month", gm)
|
loc := loadLocation(user.Timezone)
|
||||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, gy, time.Month(gm))
|
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
|
||||||
|
start, err := time.Parse("2006-01-02", startStr)
|
||||||
|
if err != nil {
|
||||||
|
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
||||||
|
}
|
||||||
|
end, err := time.Parse("2006-01-02", 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 := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("generate report", "error", err)
|
slog.Error("generate report", "error", err)
|
||||||
h.sendText(chatID, "Error generating report")
|
h.sendText(chatID, "Error generating report")
|
||||||
@@ -1219,7 +1238,7 @@ func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
|
|||||||
}
|
}
|
||||||
slog.Info("report generated", "bytes", len(data))
|
slog.Info("report generated", "bytes", len(data))
|
||||||
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
|
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
|
||||||
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", gy, gm)),
|
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
|
||||||
Bytes: data,
|
Bytes: data,
|
||||||
})
|
})
|
||||||
if _, err := h.Bot.Send(doc); err != nil {
|
if _, err := h.Bot.Send(doc); err != nil {
|
||||||
@@ -1233,7 +1252,7 @@ func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loc, _ := time.LoadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
cy, cm := now.Year(), int(now.Month())
|
cy, cm := now.Year(), int(now.Month())
|
||||||
switch user.Calendar {
|
switch user.Calendar {
|
||||||
@@ -1313,16 +1332,20 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data
|
|||||||
h.Bot.Send(edit)
|
h.Bot.Send(edit)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Convert from user's calendar to Gregorian for export
|
// Convert from user's calendar to full Gregorian range
|
||||||
gy, gm := y, m
|
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
|
||||||
switch user.Calendar {
|
start, err := time.Parse("2006-01-02", startStr)
|
||||||
case "jalali":
|
if err != nil {
|
||||||
gy, gm, _ = jalaliToGregorian(y, m, 1)
|
h.sendText(chatID, "Error processing date")
|
||||||
case "hijri":
|
return
|
||||||
gy, gm, _ = hijriToGregorian(y, m, 1)
|
|
||||||
}
|
}
|
||||||
slog.Info("export", "user_id", user.ID, "year", gy, "month", gm, "cal", user.Calendar, "cal_y", y, "cal_m", m)
|
end, err := time.Parse("2006-01-02", endStr)
|
||||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, gy, time.Month(gm))
|
if err != nil {
|
||||||
|
h.sendText(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)
|
||||||
|
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("generate report", "error", err)
|
slog.Error("generate report", "error", err)
|
||||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report")
|
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report")
|
||||||
@@ -1337,7 +1360,7 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data
|
|||||||
edit.ReplyMarkup = &kb
|
edit.ReplyMarkup = &kb
|
||||||
h.Bot.Send(edit)
|
h.Bot.Send(edit)
|
||||||
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
|
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
|
||||||
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", gy, gm)),
|
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
|
||||||
Bytes: data,
|
Bytes: data,
|
||||||
})
|
})
|
||||||
if _, err := h.Bot.Send(doc); err != nil {
|
if _, err := h.Bot.Send(doc); err != nil {
|
||||||
@@ -1367,7 +1390,7 @@ func (h *Handler) buildStatusText(chatID int64) string {
|
|||||||
|
|
||||||
last, err := h.DB.GetLastEvent(user.ID)
|
last, err := h.DB.GetLastEvent(user.ID)
|
||||||
if err == nil && last != nil && last.EventType == "in" {
|
if err == nil && last != nil && last.EventType == "in" {
|
||||||
loc, _ := time.LoadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04")
|
t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04")
|
||||||
text += fmt.Sprintf("\nStatus: working since %s", t)
|
text += fmt.Sprintf("\nStatus: working since %s", t)
|
||||||
} else {
|
} else {
|
||||||
@@ -1376,7 +1399,7 @@ func (h *Handler) buildStatusText(chatID int64) string {
|
|||||||
|
|
||||||
events, err := h.DB.EventsForDayByDayID(day.ID)
|
events, err := h.DB.EventsForDayByDayID(day.ID)
|
||||||
if err == nil && len(events) > 0 {
|
if err == nil && len(events) > 0 {
|
||||||
loc, _ := time.LoadLocation(user.Timezone)
|
loc := loadLocation(user.Timezone)
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
y, m, d := now.Date()
|
y, m, d := now.Date()
|
||||||
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
|
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import (
|
|||||||
|
|
||||||
const driverName = "sqlite"
|
const driverName = "sqlite"
|
||||||
|
|
||||||
var schemaPath = "/app/db/schema.sql"
|
var schemaPath string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
schemaPath = os.Getenv("SCHEMA_PATH")
|
||||||
|
if schemaPath == "" {
|
||||||
|
schemaPath = "/app/db/schema.sql"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
|||||||
Reference in New Issue
Block a user