feat: redesigned DB schema with users, work_types, days, events

This commit is contained in:
2026-06-24 00:36:16 +03:30
parent 8616ed0867
commit 2970b7d3fc
6 changed files with 904 additions and 242 deletions

View File

@@ -9,7 +9,25 @@ import (
"worktimeBot/internal/db"
)
func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.Month) ([]byte, error) {
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"},
}
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent string, year int, month time.Month) ([]byte, error) {
theme, ok := themes[accent]
if !ok {
theme = themes["ocean"]
}
f := excelize.NewFile()
defer f.Close()
@@ -23,12 +41,12 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M
titleStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
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},
@@ -39,32 +57,32 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M
})
dataStyle, _ := f.NewStyle(&excelize.Style{
Border: []excelize.Border{
{Type: "left", Color: "D9D9D9", Style: 1},
{Type: "right", Color: "D9D9D9", Style: 1},
{Type: "top", Color: "D9D9D9", Style: 1},
{Type: "bottom", Color: "D9D9D9", Style: 1},
{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"},
})
totalStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"D9E2F3"}},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
Border: []excelize.Border{
{Type: "left", Color: "D9D9D9", Style: 1},
{Type: "right", Color: "D9D9D9", Style: 1},
{Type: "top", Color: "D9D9D9", Style: 1},
{Type: "bottom", Color: "D9D9D9", Style: 2},
{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"},
})
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", "E1")
f.SetCellStyle(sheet, "A1", "E1", titleStyle)
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
f.MergeCell(sheet, "A1", "F1")
f.SetCellStyle(sheet, "A1", "F1", titleStyle)
f.SetRowHeight(sheet, 1, 30)
headers := []string{"Date", "Clock In", "Clock Out", "Work", "Break"}
headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
f.SetCellValue(sheet, cell, h)
@@ -75,33 +93,51 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M
f.SetColWidth(sheet, "A", "A", 14)
f.SetColWidth(sheet, "B", "B", 10)
f.SetColWidth(sheet, "C", "C", 10)
f.SetColWidth(sheet, "D", "D", 10)
f.SetColWidth(sheet, "D", "D", 12)
f.SetColWidth(sheet, "E", "E", 10)
f.SetColWidth(sheet, "F", "F", 10)
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
row := 3
loc := time.Now().Location()
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"))
if err != nil {
return nil, err
}
dayMap := make(map[string]*db.Day)
for i := range userDays {
dayMap[userDays[i].Date] = &userDays[i]
}
var totalWork, totalBreak int64
for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02")
isOff, err := store.IsDayOff(chatID, dateStr)
if err != nil {
return nil, err
day, exists := dayMap[dateStr]
if !exists {
continue
}
if isOff {
if day.IsDayOff {
continue
}
events, err := store.EventsForDay(chatID, dateStr)
events, err := store.EventsForDayByDayID(day.ID)
if err != nil {
return nil, err
}
totals := ComputeDailyTotals(events)
if len(events) == 0 {
continue
}
totals := ComputeDailyTotals(events, day.MinBreakThreshold)
totalWork += totals.TotalSeconds
totalBreak += totals.BreakSeconds
@@ -111,20 +147,25 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M
if len(events) > 0 {
cellIn, _ := excelize.CoordinatesToCellName(2, row)
cellOut, _ := excelize.CoordinatesToCellName(3, row)
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).Format("15:04"))
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format("15:04"))
f.SetCellStyle(sheet, cellIn, cellIn, dataStyle)
if events[len(events)-1].EventType == "out" {
f.SetCellValue(sheet, cellOut, time.Unix(events[len(events)-1].OccurredAt, 0).Format("15:04"))
}
}
if lastEvent := events[len(events)-1]; lastEvent.EventType == "out" {
cellOut, _ := excelize.CoordinatesToCellName(3, row)
f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format("15:04"))
f.SetCellStyle(sheet, cellOut, cellOut, dataStyle)
}
cellWork, _ := excelize.CoordinatesToCellName(4, row)
wtLabel := workTypeLabel(store, day, events)
cellType, _ := excelize.CoordinatesToCellName(4, row)
f.SetCellValue(sheet, cellType, wtLabel)
f.SetCellStyle(sheet, cellType, cellType, dataStyle)
cellWork, _ := excelize.CoordinatesToCellName(5, row)
f.SetCellValue(sheet, cellWork, fmtHHMM(totals.TotalSeconds))
f.SetCellStyle(sheet, cellWork, cellWork, dataStyle)
cellBreak, _ := excelize.CoordinatesToCellName(5, row)
cellBreak, _ := excelize.CoordinatesToCellName(6, row)
f.SetCellValue(sheet, cellBreak, fmtHHMM(totals.BreakSeconds))
f.SetCellStyle(sheet, cellBreak, cellBreak, dataStyle)
@@ -135,10 +176,10 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), totalStyle)
f.SetCellValue(sheet, fmt.Sprintf("D%d", row), fmtDDHHMM(totalWork))
f.SetCellStyle(sheet, fmt.Sprintf("D%d", row), fmt.Sprintf("D%d", row), totalStyle)
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalBreak))
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork))
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), totalStyle)
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak))
f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), totalStyle)
buf, err := f.WriteToBuffer()
if err != nil {
@@ -147,6 +188,31 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M
return buf.Bytes(), nil
}
func workTypeLabel(store *db.Store, day *db.Day, events []db.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 := store.GetWorkType(day.CurrentWorkTypeID)
if err == nil {
return wt.Name
}
return ""
}
if len(wtSeen) == 1 {
for id := range wtSeen {
wt, err := store.GetWorkType(id)
if err == nil {
return wt.Name
}
}
}
return "mixed"
}
func fmtHHMM(seconds int64) string {
hours := seconds / 3600
mins := (seconds % 3600) / 60