work/export.go
Patryk Hegenberg d8743e54c1
Some checks are pending
Go CI Pipeline / ci (push) Waiting to run
Release Builds / GoReleaser build (push) Successful in 1m4s
refactor: use slog instead of log
2025-06-11 22:41:48 +02:00

482 lines
14 KiB
Go

package main
import (
"fmt"
"log/slog"
"sort"
"strings"
"time"
"github.com/xuri/excelize/v2"
)
type DailySummary struct {
Date string
Day string
WorkStart string
WorkEnd string
BreakDuration time.Duration
WorkDuration time.Duration
Tag string
}
type ExcelEntry struct {
Date string
Day string
WorkStart string
WorkEnd string
BreakDuration string
Tag string
}
func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) {
dailyMap := make(map[string]*DailySummary)
location := yearStart.Location()
now := time.Now().In(location)
currentDay := yearStart
for currentDay.Before(yearEnd) {
dayStr := currentDay.Format("2006-01-02")
weekday := currentDay.Weekday()
tag := ""
if weekday == time.Saturday || weekday == time.Sunday {
tag = "free"
}
dailyMap[dayStr] = &DailySummary{
Date: dayStr,
Day: weekday.String()[:3],
Tag: tag,
}
currentDay = currentDay.Add(24 * time.Hour)
}
fullDayTags := make(map[string]string)
for _, entry := range entries {
if entry.StartTime.IsZero() {
slog.Warn(fmt.Sprintf("Skipping entry with zero start time (ID: %d)", entry.ID))
continue
}
startTime := entry.StartTime.In(location)
endTime := entry.EndTime.Time.In(location)
validEndTime := entry.EndTime.Valid
if !validEndTime {
endTime = now
}
if endTime.Before(yearStart) || startTime.After(yearEnd) {
continue
}
lowerTag := strings.ToLower(entry.Tag)
isPotentiallyFullDaySpecialTag := false
switch lowerTag {
case "urlaub", "krank", "feiertag", "uni", "free":
isPotentiallyFullDaySpecialTag = true
}
if isPotentiallyFullDaySpecialTag {
loopTimeForTag := startTime
for loopTimeForTag.Before(endTime) || loopTimeForTag.Equal(endTime) {
dayStr := loopTimeForTag.Format("2006-01-02")
if _, exists := dailyMap[dayStr]; exists {
existingTag := fullDayTags[dayStr]
if shouldOverwriteTag(existingTag, lowerTag) {
fullDayTags[dayStr] = lowerTag
}
}
loopTimeForTag = time.Date(loopTimeForTag.Year(), loopTimeForTag.Month(), loopTimeForTag.Day(), 0, 0, 0, 0, location).Add(24 * time.Hour)
}
}
loopTime := startTime
for loopTime.Before(endTime) {
dayStr := loopTime.Format("2006-01-02")
dayStart := time.Date(loopTime.Year(), loopTime.Month(), loopTime.Day(), 0, 0, 0, 0, location)
dayEnd := dayStart.Add(24 * time.Hour)
summary, exists := dailyMap[dayStr]
if !exists {
slog.Warn(fmt.Sprintf("Day %s not found in initial map during entry processing (ID: %d)", dayStr, entry.ID))
loopTime = dayEnd
continue
}
segmentStart := loopTime
segmentEnd := endTime
if segmentEnd.After(dayEnd) {
segmentEnd = dayEnd
}
segmentDuration := segmentEnd.Sub(segmentStart)
if segmentDuration <= 0 {
loopTime = dayEnd
continue
}
timeStr := segmentStart.Format("15:04:05")
switch lowerTag {
case TagWork:
summary.WorkDuration += segmentDuration
if summary.WorkStart == "" || timeStr < summary.WorkStart {
summary.WorkStart = timeStr
}
entryEndTimeOnThisDay := endTime
if !endTime.Truncate(24 * time.Hour).Equal(dayStart) {
entryEndTimeOnThisDay = segmentEnd
}
entryEndTimeOnThisDayStr := entryEndTimeOnThisDay.Format("15:04:05")
if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd {
summary.WorkEnd = entryEndTimeOnThisDayStr
}
if summary.Tag == "" || summary.Tag == "free" {
summary.Tag = TagWork
}
case TagBreak:
summary.BreakDuration += segmentDuration
default:
slog.Info(fmt.Sprintf("Encountered unknown tag '%s' during interval processing for entry ID %d on %s. Counting duration as 'work'.", entry.Tag, entry.ID, dayStr))
summary.WorkDuration += segmentDuration
if summary.WorkStart == "" || timeStr < summary.WorkStart {
summary.WorkStart = timeStr
}
entryEndTimeOnThisDay := endTime
if !endTime.Truncate(24 * time.Hour).Equal(dayStart) {
entryEndTimeOnThisDay = segmentEnd
}
entryEndTimeOnThisDayStr := entryEndTimeOnThisDay.Format("15:04:05")
if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd {
summary.WorkEnd = entryEndTimeOnThisDayStr
}
if summary.Tag == "" || summary.Tag == "free" {
summary.Tag = TagWork
}
}
loopTime = dayEnd
}
}
for dayStr, specialTag := range fullDayTags {
if summary, exists := dailyMap[dayStr]; exists {
if shouldOverwriteTag(summary.Tag, specialTag) {
summary.Tag = specialTag
summary.WorkStart = ""
summary.WorkEnd = ""
summary.WorkDuration = 0
summary.BreakDuration = 0
}
}
}
return dailyMap, nil
}
func shouldOverwriteTag(existingTag, newTag string) bool {
if newTag != "" && (existingTag == "" || strings.ToLower(existingTag) == "free") {
return true
}
if newTag == "" {
return false
}
priority := map[string]int{
"krank": 1,
"feiertag": 1,
"urlaub": 1,
"uni": 2,
"work": 3,
"break": 99,
"free": 100,
}
prioExisting, okExisting := priority[strings.ToLower(existingTag)]
if !okExisting {
prioExisting = 999
}
prioNew, okNew := priority[strings.ToLower(newTag)]
if !okNew {
prioNew = 999
}
return prioNew < prioExisting || (prioNew == prioExisting && strings.ToLower(newTag) != "work")
}
func convertDailyToExcelEntries(dailySummaries map[string]*DailySummary) []ExcelEntry {
excelEntries := make([]ExcelEntry, 0, len(dailySummaries))
dates := make([]string, 0, len(dailySummaries))
for d := range dailySummaries {
dates = append(dates, d)
}
sort.Strings(dates)
for _, dateStr := range dates {
summary := dailySummaries[dateStr]
entry := ExcelEntry{
Date: summary.Date,
Day: summary.Day,
WorkStart: summary.WorkStart,
WorkEnd: summary.WorkEnd,
BreakDuration: formatDuration(summary.BreakDuration),
Tag: summary.Tag,
}
excelEntries = append(excelEntries, entry)
}
return excelEntries
}
func formatDuration(d time.Duration) string {
if d < 0 {
d = -d
sign := "-"
d = d.Round(time.Second)
h := int64(d.Hours())
m := int64(d.Minutes()) % 60
s := int64(d.Seconds()) % 60
return fmt.Sprintf("%s%02d:%02d:%02d", sign, h, m, s)
}
d = d.Round(time.Second)
h := int64(d.Hours())
m := int64(d.Minutes()) % 60
s := int64(d.Seconds()) % 60
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
}
func getSollExcelTime(dayOfWeek string) any {
var sollString string
switch dayOfWeek {
case "Mon", "Tue", "Thu", "Fri":
sollString = "08:00"
case "Wed":
sollString = "04:00"
default:
return nil
}
sollDur, err := time.Parse("15:04", sollString)
if err != nil {
slog.Error(fmt.Sprintf("Could not parse hardcoded soll string '%s': %v", sollString, err))
return nil
}
return float64(sollDur.Hour())/24.0 + float64(sollDur.Minute())/(24.0*60.0)
}
func writeExcelSheet(entries []ExcelEntry, name string) error {
f := excelize.NewFile()
defer func() {
if err := f.Close(); err != nil {
slog.Error(fmt.Sprintf("Failed to close excel file handle: %v", err))
}
}()
sheetName := "Zeiten"
if len(entries) > 0 {
if t, err := time.Parse("2006-01-02", entries[0].Date); err == nil {
sheetName = fmt.Sprintf("%d", t.Year())
}
}
index, err := f.NewSheet(sheetName)
if err != nil {
existingIndex, _ := f.GetSheetIndex(sheetName)
if existingIndex == -1 {
sheetName = "Sheet1"
index, _ = f.GetSheetIndex(sheetName)
if index == -1 {
return fmt.Errorf("could not create or find sheet '%s' or 'Sheet1': %w", sheetName, err)
}
} else {
index = existingIndex
}
}
defaultSheetName := "Sheet1"
defaultSheetIndex, _ := f.GetSheetIndex(defaultSheetName)
if sheetName != defaultSheetName && defaultSheetIndex != -1 {
f.DeleteSheet(defaultSheetName)
}
f.SetCellValue(sheetName, "B1", "Arbeitszeiten "+sheetName)
f.MergeCell(sheetName, "B1", "O1")
f.SetCellValue(sheetName, "B3", "Datum")
f.SetCellValue(sheetName, "C3", "Tag")
f.SetCellValue(sheetName, "D3", "Status / Zeit")
f.MergeCell(sheetName, "D3", "E3")
f.SetCellValue(sheetName, "G3", "Dauer")
f.MergeCell(sheetName, "G3", "H3")
f.SetCellValue(sheetName, "I3", "Pause")
f.SetCellValue(sheetName, "J3", "Netto")
f.SetCellValue(sheetName, "K3", "Soll")
f.SetCellValue(sheetName, "L3", "Saldo")
f.SetCellValue(sheetName, "N3", "Saldo Kumuliert")
f.MergeCell(sheetName, "N3", "O3")
f.SetCellValue(sheetName, "D4", "von / Status")
f.SetCellValue(sheetName, "E4", "bis")
f.SetCellValue(sheetName, "G4", "brutto")
f.SetCellValue(sheetName, "H4", "")
f.SetCellValue(sheetName, "J4", "Ist (Netto)")
f.SetCellValue(sheetName, "K4", "")
f.SetCellValue(sheetName, "L4", "Tag")
f.SetCellValue(sheetName, "N4", "Total")
f.SetCellValue(sheetName, "O4", "")
timeStyleCode := "hh:mm"
timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode})
dateStyleCode := "dd.mm.yyyy"
dateStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &dateStyleCode})
saldoStyleCode := "[h]:mm;[RED]-[h]:mm"
saldoStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &saldoStyleCode})
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true},
Alignment: &excelize.Alignment{Horizontal: "center"},
})
centerStyle, _ := f.NewStyle(&excelize.Style{Alignment: &excelize.Alignment{Horizontal: "center"}})
f.SetCellStyle(sheetName, "B3", "O4", headerStyle)
f.SetCellStyle(sheetName, "B1", "O1", headerStyle)
startRow := 6
for i, entry := range entries {
row := startRow + i
rowStr := fmt.Sprintf("%d", row)
tagLower := strings.ToLower(entry.Tag)
dateValue, err := time.Parse("2006-01-02", entry.Date)
if err == nil {
f.SetCellValue(sheetName, "B"+rowStr, dateValue)
f.SetCellStyle(sheetName, "B"+rowStr, "B"+rowStr, dateStyle)
} else {
f.SetCellValue(sheetName, "B"+rowStr, entry.Date)
}
f.SetCellValue(sheetName, "C"+rowStr, entry.Day)
sollExcelTime := getSollExcelTime(entry.Day)
if sollExcelTime != nil {
f.SetCellValue(sheetName, "K"+rowStr, sollExcelTime)
f.SetCellStyle(sheetName, "K"+rowStr, "K"+rowStr, timeStyle)
} else {
f.SetCellValue(sheetName, "K"+rowStr, "")
}
switch tagLower {
case TagWork, "":
if entry.WorkStart != "" && entry.WorkEnd != "" {
startTime, _ := time.Parse("15:04:05", entry.WorkStart)
endTime, _ := time.Parse("15:04:05", entry.WorkEnd)
startExcelTime := float64(startTime.Hour())/24.0 + float64(startTime.Minute())/(24.0*60.0) + float64(startTime.Second())/(24.0*60.0*60.0)
endExcelTime := float64(endTime.Hour())/24.0 + float64(endTime.Minute())/(24.0*60.0) + float64(endTime.Second())/(24.0*60.0*60.0)
if endExcelTime < startExcelTime {
endExcelTime += 1.0
}
f.SetCellValue(sheetName, "D"+rowStr, startExcelTime)
f.SetCellStyle(sheetName, "D"+rowStr, "D"+rowStr, timeStyle)
f.SetCellValue(sheetName, "E"+rowStr, endExcelTime)
f.SetCellStyle(sheetName, "E"+rowStr, "E"+rowStr, timeStyle)
f.SetCellFormula(sheetName, "G"+rowStr, fmt.Sprintf("E%d-D%d", row, row))
f.SetCellStyle(sheetName, "G"+rowStr, "H"+rowStr, saldoStyle) // Saldo-Style für Dauer
breakDur, _ := time.Parse("15:04:05", entry.BreakDuration)
breakExcelTime := float64(breakDur.Hour())/24.0 + float64(breakDur.Minute())/(24.0*60.0) + float64(breakDur.Second())/(24.0*60.0*60.0)
thirtyMinBreak := float64(30) / (24 * 60)
if breakExcelTime < thirtyMinBreak {
breakExcelTime = thirtyMinBreak
}
f.SetCellValue(sheetName, "I"+rowStr, breakExcelTime)
f.SetCellStyle(sheetName, "I"+rowStr, "I"+rowStr, timeStyle)
f.SetCellFormula(sheetName, "J"+rowStr, fmt.Sprintf("MAX(0, G%d-I%d)", row, row))
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
} else {
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
}
case "urlaub", "uni":
text := ""
if tagLower == "urlaub" {
text = "Urlaub"
} else {
text = "Hochschule"
}
f.SetCellValue(sheetName, "D"+rowStr, text)
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
if sollExcelTime != nil {
f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime)
} else {
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
}
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
case "feiertag", "krank":
text := ""
if tagLower == "feiertag" {
text = "Feiertag"
} else {
text = "Krank"
}
f.SetCellValue(sheetName, "D"+rowStr, text)
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
if sollExcelTime != nil {
f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime)
} else {
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
}
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
case "free":
f.SetCellValue(sheetName, "D"+rowStr, "")
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
// J: Netto ist 0
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
default: // Unbekannte Tags oder Tage ohne Eintrag
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
}
f.SetCellFormula(sheetName, "L"+rowStr, fmt.Sprintf("J%d-K%d", row, row))
f.SetCellStyle(sheetName, "L"+rowStr, "M"+rowStr, saldoStyle)
if i == 0 {
f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("L%d", row))
} else {
prevSaldoTotalCell := fmt.Sprintf("N%d", row-1)
f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("%s+L%d", prevSaldoTotalCell, row))
}
f.SetCellStyle(sheetName, "N"+rowStr, "O"+rowStr, saldoStyle)
}
f.SetColWidth(sheetName, "B", "B", 12)
f.SetColWidth(sheetName, "C", "C", 5)
f.SetColWidth(sheetName, "D", "E", 10)
f.SetColWidth(sheetName, "F", "F", 2)
f.SetColWidth(sheetName, "G", "H", 9)
f.SetColWidth(sheetName, "I", "I", 9)
f.SetColWidth(sheetName, "J", "J", 9)
f.SetColWidth(sheetName, "K", "K", 9)
f.SetColWidth(sheetName, "L", "M", 9)
f.SetColWidth(sheetName, "N", "O", 10)
f.SetActiveSheet(index)
if err := f.SaveAs(name); err != nil {
return fmt.Errorf("failed to save excel file as '%s': %w", name, err)
}
return nil
}