package store import ( "context" "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 (s *Store) ExportSummary(ctx context.Context, filename string) error { slog.Info(fmt.Sprintf("Starting export to '%s'...", filename)) currentYear := time.Now().Year() location := time.Local yearStart := time.Date(currentYear, 1, 1, 0, 0, 0, 0, location) yearEnd := yearStart.AddDate(1, 0, 0) slog.Info(fmt.Sprintf("Exporting data for year %d (%s to %s)", currentYear, yearStart.Format("2006-01-02"), yearEnd.Format("2006-01-02"))) query := ` SELECT id, tag, start_time, end_time FROM time_entries WHERE start_time < ? AND (end_time IS NULL OR end_time > ?) ORDER BY start_time ASC;` rows, err := s.db.QueryContext(ctx, query, yearEnd, yearStart) if err != nil { return fmt.Errorf("failed to query entries for year export: %w", err) } defer rows.Close() var entries []TimeEntry for rows.Next() { var entry TimeEntry if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil { return fmt.Errorf("failed to scan entry row: %w", err) } entries = append(entries, entry) } if err = rows.Err(); err != nil { return fmt.Errorf("error during export row iteration: %w", err) } slog.Info(fmt.Sprintf("Found %d potentially relevant time entries.", len(entries))) dailySummaries, err := aggregateEntriesToDailySummaries(entries, yearStart, yearEnd) if err != nil { return fmt.Errorf("failed to aggregate entries for export: %w", err) } excelEntries := convertDailyToExcelEntries(dailySummaries) if len(excelEntries) == 0 { slog.Warn("No daily summaries generated for the export period.") fmt.Println("No data available to generate the export for the specified period.") return nil } if err := writeExcelSheet(excelEntries, filename); err != nil { return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err) } slog.Info(fmt.Sprintf("Successfully exported timetable to %s", filename)) fmt.Printf("Successfully exported timetable to %s\n", filename) return nil } 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("Skipping entry with zero start time", "ID", entry.ID) continue } startTime := entry.StartTime.In(location) endTime := entry.EndTime.Time.In(location) if !entry.EndTime.Valid { 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 { 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: 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 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("Failed to close excel file handle", "error", 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 sheet '%s': %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", "") toExcelTime := func(t time.Time) float64 { return float64(t.Hour())/24.0 + float64(t.Minute())/(24.0*60.0) + float64(t.Second())/(24.0*60.0*60.0) } 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 := toExcelTime(startTime) endExcelTime := toExcelTime(endTime) 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) breakDur, _ := time.Parse("15:04:05", entry.BreakDuration) breakExcelTime := toExcelTime(breakDur) 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 }