feat: Hijri calendar, virtual midnight crossover, overlap rejection, WAL mode

- Hijri (قمری) calendar added as 3rd calendar option with tabular conversion
- Calendar type select menu (like accent) with gregorian/jalali/hijri
- Calendar type applied to exports and reports
- Midnight crossover: virtual splits at computation time (no synthetic DB events)
  - Handles both UTC and local timezone boundaries
  - Timezone changes handled naturally (recomputed on-the-fly)
- Rate limiting per user ID instead of chat ID
- WAL mode enabled for SQLite
- Smart reply keyboard updates after every state change
- Overlapping time edits rejected (must preserve in/out alternation)
- Single event deletion warns if timeline needs repair
- Day off no longer prevents clock in/out
- Accent colors shown with descriptive names in select menu
- Delete button label changed to DEL for visual distinction
This commit is contained in:
2026-06-24 09:39:15 +03:30
parent 6f6cc6f184
commit 9d123316e6
6 changed files with 1030 additions and 59 deletions

View File

@@ -22,7 +22,7 @@ var themes = map[string]themeColors{
"catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"},
}
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent string, year int, month time.Month) ([]byte, error) {
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, year int, month time.Month) ([]byte, error) {
theme, ok := themes[accent]
if !ok {
theme = themes["ocean"]
@@ -76,7 +76,15 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent strin
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
monthName := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).Format("January 2006")
var monthName string
switch calendar {
case "jalali":
monthName = fmt.Sprintf("%s %d", jalaliMonthNames[month-1], year)
case "hijri":
monthName = fmt.Sprintf("%s %d", hijriMonthNames[month-1], year)
default:
monthName = time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).Format("January 2006")
}
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
f.MergeCell(sheet, "A1", "F1")
f.SetCellStyle(sheet, "A1", "F1", titleStyle)
@@ -120,15 +128,21 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent strin
for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02")
displayDate := dateStr
if calendar == "jalali" {
gy, gm, gd := parseGregorianDateKey(dateStr)
jy, jm, jd := gregorianToJalali(gy, gm, gd)
displayDate = fmt.Sprintf("%04d-%02d-%02d", jy, jm, jd)
} else if calendar == "hijri" {
gy, gm, gd := parseGregorianDateKey(dateStr)
hy, hm, hd := gregorianToHijri(gy, gm, gd)
displayDate = fmt.Sprintf("%04d-%02d-%02d", hy, hm, hd)
}
day, exists := dayMap[dateStr]
if !exists {
continue
}
if day.IsDayOff {
continue
}
events, err := store.EventsForDayByDayID(day.ID)
if err != nil {
return nil, err
@@ -137,12 +151,14 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent strin
continue
}
totals := ComputeDailyTotals(events, day.MinBreakThreshold)
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 := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
totalWork += totals.TotalSeconds
totalBreak += totals.BreakSeconds
cellDate, _ := excelize.CoordinatesToCellName(1, row)
f.SetCellValue(sheet, cellDate, dateStr)
f.SetCellValue(sheet, cellDate, displayDate)
f.SetCellStyle(sheet, cellDate, cellDate, dataStyle)
if len(events) > 0 {