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) f.SetCellValue(sheetName, "J"+rowStr, 0.0) f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) default: 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 }