package main import ( "fmt" "log" "sort" "strings" "time" "github.com/xuri/excelize/v2" ) type DailySummary struct { Date string // YYYY-MM-DD Format Day string // Mon, Tue, etc. WorkStart string // HH:MM:SS - Frühester Arbeitsbeginn WorkEnd string // HH:MM:SS - Spätestes Arbeitsende BreakDuration time.Duration // Summe aller Pausenzeiten an diesem Tag WorkDuration time.Duration // Summe aller Arbeitszeiten an diesem Tag Tag string // Spezielle Tags für den Tag (Urlaub, Krank, Feiertag, Uni, Free...) } type ExcelEntry struct { Date string // YYYY-MM-DD Day string // Mon, Tue, etc. WorkStart string // HH:MM:SS WorkEnd string // HH:MM:SS BreakDuration string // HH:MM:SS (formatiert) Tag string // Spezielle Tags } func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) { dailyMap := make(map[string]*DailySummary) location := yearStart.Location() // Verwende die Zeitzone des Startdatums now := time.Now().In(location) // Aktuelle Zeit für laufende Einträge currentDay := yearStart log.Println(currentDay) 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], // Mon, Tue, etc. Tag: tag, // Initialisiere mit "free" für Wochenende, sonst leer } currentDay = currentDay.Add(24 * time.Hour) // Gehe zum nächsten Tag } fullDayTags := make(map[string]string) // Map, um ganztägige Ereignisse zu speichern (Datum -> Tag) for _, entry := range entries { if entry.StartTime.IsZero() { log.Printf("WARN: Skipping entry with zero start time (ID: %d)", entry.ID) continue } startTime := entry.StartTime.In(location) // Stelle sicher, dass Zeiten in der korrekten Zeitzone sind endTime := entry.EndTime.Time.In(location) validEndTime := entry.EndTime.Valid if !validEndTime { endTime = now // Nimm aktuelle Zeit für laufende Einträge } 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") // Nur Tage innerhalb des Jahres berücksichtigen 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 { log.Printf("WARN: Day %s not found in initial map during entry processing (ID: %d)", dayStr, entry.ID) loopTime = dayEnd // Gehe zum nächsten Tag continue } segmentStart := loopTime segmentEnd := endTime if segmentEnd.After(dayEnd) { segmentEnd = dayEnd } segmentDuration := segmentEnd.Sub(segmentStart) if segmentDuration <= 0 { // Überspringe leere Segmente loopTime = dayEnd continue } timeStr := segmentStart.Format("15:04:05") // endTimeStr := segmentEnd.Format("15:04:05") // Ende des Segments switch lowerTag { case TagWork: // Konstante verwenden 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: // Konstante verwenden summary.BreakDuration += segmentDuration default: log.Printf("INFO: 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 // Behandle unbekannt wie Arbeit für den Tag-Typ } } loopTime = dayEnd // Gehe zum nächsten Tag } } for dayStr, specialTag := range fullDayTags { if summary, exists := dailyMap[dayStr]; exists { // Nur überschreiben, wenn der neue Tag höhere oder gleiche Prio hat 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, // Arbeit hat niedrigere Priorität als spezielle Tage "break": 99, // Pause sollte nie der Haupt-Tag sein "free": 100, // Frei hat niedrigste Priorität } 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) // Sortiere die Datums-Strings (YYYY-MM-DD) 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), // Formatierte Dauer Tag: summary.Tag, // Übernehme den finalen Tag } excelEntries = append(excelEntries, entry) } return excelEntries } func formatDuration(d time.Duration) string { if d < 0 { d = -d // Arbeite mit positivem Wert für die Berechnung 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": // Standard-Arbeitstage sollString = "08:00" case "Wed": // Kurzer Tag sollString = "04:00" default: // Sa, So return nil // Kein Soll an diesen Tagen } sollDur, err := time.Parse("15:04", sollString) if err != nil { log.Printf("ERROR: Could not parse hardcoded soll string '%s': %v", sollString, err) return nil // Fehler beim Parsen } return float64(sollDur.Hour())/24.0 + float64(sollDur.Minute())/(24.0*60.0) } func writeExcelSheet(entries []ExcelEntry, name string) error { // Sortierung erfolgt jetzt in convertDailyToExcelEntries f := excelize.NewFile() defer func() { if err := f.Close(); err != nil { log.Printf("ERROR: 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" // Fallback auf Default 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") // Titel angepasst f.MergeCell(sheetName, "D3", "E3") f.SetCellValue(sheetName, "G3", "Dauer") // Titel angepasst 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)") // Titel angepasst f.SetCellValue(sheetName, "K4", "") f.SetCellValue(sheetName, "L4", "Tag") f.SetCellValue(sheetName, "N4", "Total") f.SetCellValue(sheetName, "O4", "") timeStyleCode := "hh:mm" // Format für Zeitpunkte und Dauer < 24h timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode}) dateStyleCode := "dd.mm.yyyy" // Format für Datum dateStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &dateStyleCode}) saldoStyleCode := "[h]:mm;[RED]-[h]:mm" // Verwende [h] um Stunden > 24 zu erlauben 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"}}) // Stil für Zellen, die leer bleiben sollen (optional, um z.B. 0 auszublenden) // emptyStyle, _ := f.NewStyle(&excelize.Style{Fill: excelize.Fill{Type: "pattern", Color: []string{"#FFFFFF"}, Pattern: 1}}) // Weißer Hintergrund f.SetCellStyle(sheetName, "B3", "O4", headerStyle) f.SetCellStyle(sheetName, "B1", "O1", headerStyle) // Titel auch 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) // Fallback } 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, "") // Leer für Wochenende etc. } switch tagLower { case TagWork, "": // Normaler Arbeitstag oder nicht speziell getaggter Tag 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) // Saldo-Style für Dauer } 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) // Verbinde D bis I 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) // Kein Soll (WE), Netto 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) // Verbinde D bis I 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) // Kein Soll (WE), Netto 0 } f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) case "free": // Wochenende oder explizit "free" f.SetCellValue(sheetName, "D"+rowStr, "") f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr) // Verbinde D bis I 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) // Style auf L und M if i == 0 { // Erste Datenzeile 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) // Style auf N und O } f.SetColWidth(sheetName, "B", "B", 12) // Datum f.SetColWidth(sheetName, "C", "C", 5) // Tag f.SetColWidth(sheetName, "D", "E", 10) // Status/Zeit von/bis f.SetColWidth(sheetName, "F", "F", 2) // Leer f.SetColWidth(sheetName, "G", "H", 9) // Dauer brutto f.SetColWidth(sheetName, "I", "I", 9) // Pause f.SetColWidth(sheetName, "J", "J", 9) // Netto f.SetColWidth(sheetName, "K", "K", 9) // Soll f.SetColWidth(sheetName, "L", "M", 9) // Saldo Tag f.SetColWidth(sheetName, "N", "O", 10) // Saldo Total 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 }