diff --git a/app.go b/app.go index 417772c..3181e43 100644 --- a/app.go +++ b/app.go @@ -13,24 +13,27 @@ import ( ) type App struct { - cfg Config - flags Flags + cfg Config + flags Flags + tracker *SQLiteTimeTracker // Hinzufügen } -func NewApp() (*App, error) { +func NewApp(tracker *SQLiteTimeTracker) (*App, error) { cfg, err := loadConfig() if err != nil { return nil, fmt.Errorf("error loading config: %w", err) } return &App{ - cfg: cfg, + cfg: cfg, + tracker: tracker, // Speichern }, nil } func (a *App) connect() { - tw := NewTimeWarrior() - tw.StartWork() + // tw := NewTimeWarrior() + // tw.StartWork() + a.tracker.StartWork() a.wakeWorkstation() sshCon, err := a.newSSHConnection() if err != nil { @@ -119,7 +122,7 @@ func (a *App) startRDPConnection() { func (a *App) makeChoice() { var choice string - tw := NewTimeWarrior() + // tw := NewTimeWarrior() form := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). @@ -148,26 +151,44 @@ func (a *App) makeChoice() { switch choice { case "start work": - a.connect() + a.connect() // connect nutzt jetzt a.tracker case "stop work": - tw.StopWork() + a.tracker.StopWork() case "start break": - tw.StartBreak() + a.tracker.StartBreak() case "stop break": - tw.StopBreak() + a.tracker.StopBreak() case "show week summary": - tw.ShowSummary(":week") + a.tracker.ShowSummary(":week") case "show month summary": - tw.ShowSummary(":month") - case "connect to jump": - a.connectToJump() - case "connect to workstation": - a.connectToWorkstation() - case "start rdp connection": - a.startRDPConnection() + a.tracker.ShowSummary(":month") case "export": - tw.ExportSummary(a.flags.ExportName) + if err := a.tracker.ExportSummary(a.flags.ExportName); err != nil { + log.Printf("Error exporting summary: %v", err) + } } + // switch choice { + // case "start work": + // a.connect() + // case "stop work": + // tw.StopWork() + // case "start break": + // tw.StartBreak() + // case "stop break": + // tw.StopBreak() + // case "show week summary": + // tw.ShowSummary(":week") + // case "show month summary": + // tw.ShowSummary(":month") + // case "connect to jump": + // a.connectToJump() + // case "connect to workstation": + // a.connectToWorkstation() + // case "start rdp connection": + // a.startRDPConnection() + // case "export": + // tw.ExportSummary(a.flags.ExportName) + // } } func (a *App) getSSHAuth() ssh.AuthMethod { diff --git a/cmd.go b/cmd.go index 3de9559..fa28f6a 100644 --- a/cmd.go +++ b/cmd.go @@ -1,8 +1,8 @@ package main import ( + "fmt" "log" - "os" "github.com/spf13/cobra" ) @@ -31,41 +31,91 @@ func (a *App) startCommand() *cobra.Command { } } +// cmd.go (Auszüge) func (a *App) stopCommand() *cobra.Command { return &cobra.Command{ - Use: "stop", - Short: "stop work", - Long: "command to stop the work day", - Run: func(cmd *cobra.Command, args []string) { - tw := NewTimeWarrior() - tw.StopWork() - if err := a.killForwardings(); err != nil { - log.Printf("error stoping port forwarding: %v", err) + // ... Use, Short, Long ... + RunE: func(cmd *cobra.Command, args []string) error { // RunE verwenden für Fehlerbehandlung + // tw := NewTimeWarrior() // Entfernen + if err := a.tracker.StopWork(); err != nil { + log.Printf("Error stopping work: %v", err) + // return err // Fehler zurückgeben, nicht os.Exit } - os.Exit(0) + // Das Beenden der Forwardings ist hier richtig platziert + if err := a.killForwardings(); err != nil { + log.Printf("error stopping port forwarding: %v", err) + // Ggf. auch hier Fehler zurückgeben oder nur loggen + } + // Kein os.Exit(0) hier! + return nil // Erfolg signalisieren }, } } func (a *App) showCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "show", - Short: "show timetracking", - Long: "show different timetracking", - Run: func(cmd *cobra.Command, args []string) { - tw := NewTimeWarrior() + // ... Use, Short, Long ... + RunE: func(cmd *cobra.Command, args []string) error { // RunE verwenden + // tw := NewTimeWarrior() // Entfernen + var err error if a.flags.ShowExport { - tw.ExportSummary(a.flags.ExportName) + err = a.tracker.ExportSummary(a.flags.ExportName) + } else if a.flags.ShowWeek { // else if, falls nur eine Aktion gewünscht + err = a.tracker.ShowSummary(":week") + } else if a.flags.ShowMonth { + err = a.tracker.ShowSummary(":month") + } else { + // Standardaktion, wenn kein Flag gesetzt ist? Oder Fehler? + fmt.Println("Please specify a flag: --week, --month, or --export") } - if a.flags.ShowWeek { - tw.ShowSummary(":week") + + if err != nil { + log.Printf("Error in show command: %v", err) + return err // Fehler zurückgeben } - if a.flags.ShowMonth { - tw.ShowSummary(":month") - } - os.Exit(0) + // Kein os.Exit(0)! + return nil }, } + // Flags bleiben gleich... + // return cmd + // } + + // func (a *App) stopCommand() *cobra.Command { + // return &cobra.Command{ + // Use: "stop", + // Short: "stop work", + // Long: "command to stop the work day", + // Run: func(cmd *cobra.Command, args []string) { + // tw := NewTimeWarrior() + // tw.StopWork() + // if err := a.killForwardings(); err != nil { + // log.Printf("error stoping port forwarding: %v", err) + // } + // os.Exit(0) + // }, + // } + // } + // + // func (a *App) showCommand() *cobra.Command { + // cmd := &cobra.Command{ + // Use: "show", + // Short: "show timetracking", + // Long: "show different timetracking", + // Run: func(cmd *cobra.Command, args []string) { + // tw := NewTimeWarrior() + // if a.flags.ShowExport { + // tw.ExportSummary(a.flags.ExportName) + // } + // if a.flags.ShowWeek { + // tw.ShowSummary(":week") + // } + // if a.flags.ShowMonth { + // tw.ShowSummary(":month") + // } + // os.Exit(0) + // }, + // } cmd.Flags().BoolVarP(&a.flags.ShowWeek, "week", "w", false, "show timewarrior week summary") cmd.Flags().BoolVarP(&a.flags.ShowMonth, "month", "m", false, "show timewarrior month summary") diff --git a/config.go b/config.go index ef79ab0..9eced30 100644 --- a/config.go +++ b/config.go @@ -53,3 +53,16 @@ func loadConfig() (Config, error) { return cfg, nil } + +func getDBPath() (string, error) { + configPath, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("cannot get user config dir: %w", err) + } + workConfigPath := filepath.Join(configPath, "work") + // Sicherstellen, dass das Verzeichnis existiert + if err := os.MkdirAll(workConfigPath, 0750); err != nil { + return "", fmt.Errorf("cannot create config directory %s: %w", workConfigPath, err) + } + return filepath.Join(workConfigPath, "work_time.db"), nil +} diff --git a/go.mod b/go.mod index 0d287fc..f098e7d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.0 require ( github.com/charmbracelet/huh v0.5.3 + github.com/mattn/go-sqlite3 v1.14.24 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 github.com/xuri/excelize/v2 v2.9.0 diff --git a/go.sum b/go.sum index 31108ec..fab3f91 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= diff --git a/main.go b/main.go index 5a18829..c4359c8 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,12 @@ import ( ) func main() { - app, err := NewApp() + tracker, err := NewSQLiteTimeTracker() + if err != nil { + log.Fatalf("unable to setup database connection: %v", err) + } + defer tracker.Close() // Wichtig: DB-Verbindung schließen + app, err := NewApp(tracker) if err != nil { log.Fatalf("unable to setup application: %v", err) } diff --git a/timewarrior.go b/timewarrior similarity index 100% rename from timewarrior.go rename to timewarrior diff --git a/tracker_sqlite.go b/tracker_sqlite.go new file mode 100644 index 0000000..e2734e3 --- /dev/null +++ b/tracker_sqlite.go @@ -0,0 +1,565 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "sort" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" // SQLite driver + "github.com/xuri/excelize/v2" +) + +// Stellt die Datenbankverbindung und Methoden zur Zeitverfolgung bereit +type SQLiteTimeTracker struct { + db *sql.DB +} + +// Repräsentiert ein Zeitintervall aus der Datenbank +type TimeInterval struct { + ID int64 + StartTime time.Time + EndTime sql.NullTime // Wichtig für offene Intervalle + Tag string +} + +// Initialisiert die Datenbankverbindung und erstellt die Tabelle, falls nötig +func NewSQLiteTimeTracker() (*SQLiteTimeTracker, error) { + dbPath, err := getDBPath() + if err != nil { + return nil, err + } + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("cannot open database %s: %w", dbPath, err) + } + + // Tabelle erstellen, falls sie nicht existiert + createTableSQL := ` + CREATE TABLE IF NOT EXISTS time_intervals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_time DATETIME NOT NULL, + end_time DATETIME DEFAULT NULL, + tag TEXT NOT NULL + );` + if _, err := db.Exec(createTableSQL); err != nil { + db.Close() // Aufräumen bei Fehler + return nil, fmt.Errorf("cannot create time_intervals table: %w", err) + } + + return &SQLiteTimeTracker{db: db}, nil +} + +// Schließt die Datenbankverbindung +func (t *SQLiteTimeTracker) Close() error { + if t.db != nil { + return t.db.Close() + } + return nil +} + +// Findet das aktuell laufende (offene) Zeitintervall +func (t *SQLiteTimeTracker) findOpenInterval() (*TimeInterval, error) { + query := `SELECT id, start_time, end_time, tag FROM time_intervals WHERE end_time IS NULL ORDER BY start_time DESC LIMIT 1;` + row := t.db.QueryRow(query) + + var interval TimeInterval + err := row.Scan(&interval.ID, &interval.StartTime, &interval.EndTime, &interval.Tag) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil // Kein offenes Intervall gefunden, kein Fehler + } + return nil, fmt.Errorf("error querying open interval: %w", err) + } + return &interval, nil +} + +// Stoppt das aktuell offene Intervall (setzt end_time) +func (t *SQLiteTimeTracker) stopCurrentInterval(now time.Time) error { + openInterval, err := t.findOpenInterval() + if err != nil { + return err // Fehler beim Suchen + } + if openInterval == nil { + return nil // Nichts zu stoppen + } + + updateSQL := `UPDATE time_intervals SET end_time = ? WHERE id = ?;` + _, err = t.db.Exec(updateSQL, now, openInterval.ID) + if err != nil { + return fmt.Errorf("error updating end_time for interval %d: %w", openInterval.ID, err) + } + fmt.Printf("Stopped interval: %s (ID: %d)\n", openInterval.Tag, openInterval.ID) + return nil +} + +// Startet ein neues Intervall mit dem gegebenen Tag +func (t *SQLiteTimeTracker) startNewInterval(tag string, now time.Time) error { + // Zuerst das alte Intervall stoppen + if err := t.stopCurrentInterval(now); err != nil { + return fmt.Errorf("failed to stop previous interval before starting new one: %w", err) + } + + insertSQL := `INSERT INTO time_intervals (start_time, tag) VALUES (?, ?);` + res, err := t.db.Exec(insertSQL, now, tag) + if err != nil { + return fmt.Errorf("error inserting new interval with tag %s: %w", tag, err) + } + newID, _ := res.LastInsertId() + fmt.Printf("Started interval: %s (ID: %d)\n", tag, newID) + return nil +} + +// --- Implementierung der ursprünglichen TimeWarrior-Methoden --- + +func (t *SQLiteTimeTracker) StartWork() error { + return t.startNewInterval("work", time.Now()) +} + +func (t *SQLiteTimeTracker) StopWork() error { + // Stoppt das *letzte* offene Intervall, egal welcher Tag + return t.stopCurrentInterval(time.Now()) +} + +func (t *SQLiteTimeTracker) StartBreak() error { + return t.startNewInterval("break", time.Now()) +} + +// Stoppt die Pause und startet 'work' wieder (entspricht altem `timew track work`) +func (t *SQLiteTimeTracker) StopBreak() error { + // `startNewInterval` stoppt implizit das vorherige ('break') + return t.startNewInterval("work", time.Now()) +} + +// Holt Intervalle für einen gegebenen Zeitraum +func (t *SQLiteTimeTracker) getIntervals(start, end time.Time) ([]TimeInterval, error) { + query := `SELECT id, start_time, end_time, tag FROM time_intervals WHERE start_time >= ? AND (end_time <= ? OR end_time IS NULL) ORDER BY start_time ASC;` + rows, err := t.db.Query(query, start, end) + if err != nil { + return nil, fmt.Errorf("error querying intervals between %s and %s: %w", start, end, err) + } + defer rows.Close() + + var intervals []TimeInterval + for rows.Next() { + var interval TimeInterval + // Scanne auch end_time, auch wenn es NULL sein könnte + err := rows.Scan(&interval.ID, &interval.StartTime, &interval.EndTime, &interval.Tag) + if err != nil { + return nil, fmt.Errorf("error scanning interval row: %w", err) + } + intervals = append(intervals, interval) + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error after iterating interval rows: %w", err) + } + + return intervals, nil +} + +// Berechnet Start- und Enddatum für relative Perioden wie ":week", ":month", ":year" +func calculateDateRange(period string) (time.Time, time.Time) { + now := time.Now() + year, month, day := now.Date() + loc := now.Location() + + switch period { + case ":week": + // Gehe zum Anfang der Woche (Montag) + weekday := now.Weekday() + daysToSubtract := int(weekday) - int(time.Monday) + if daysToSubtract < 0 { + daysToSubtract += 7 + } + startOfWeek := time.Date(year, month, day-daysToSubtract, 0, 0, 0, 0, loc) + endOfWeek := startOfWeek.AddDate(0, 0, 7).Add(-1 * time.Nanosecond) // Ende Sonntag + return startOfWeek, endOfWeek + case ":month": + startOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, loc) + endOfMonth := startOfMonth.AddDate(0, 1, 0).Add(-1 * time.Nanosecond) // Ende des Monats + return startOfMonth, endOfMonth + case ":year": + startOfYear := time.Date(year, 1, 1, 0, 0, 0, 0, loc) + endOfYear := startOfYear.AddDate(1, 0, 0).Add(-1 * time.Nanosecond) // Ende des Jahres + return startOfYear, endOfYear + default: + // Standard: heutiger Tag + startOfDay := time.Date(year, month, day, 0, 0, 0, 0, loc) + endOfDay := startOfDay.AddDate(0, 0, 1).Add(-1 * time.Nanosecond) + return startOfDay, endOfDay + } +} + +func (t *SQLiteTimeTracker) ShowSummary(period string) error { + start, end := calculateDateRange(period) + fmt.Printf("Summary for %s (%s to %s)\n", period, start.Format("2006-01-02"), end.Format("2006-01-02")) + + intervals, err := t.getIntervals(start, end) + if err != nil { + return err + } + + if len(intervals) == 0 { + fmt.Println("No data for this period.") + return nil + } + + // Aggregieren der Daten (vereinfachtes Beispiel) + var totalWork time.Duration + var totalBreak time.Duration + tags := make(map[string]time.Duration) + + for _, interval := range intervals { + endTime := time.Now() // Nimm aktuelle Zeit, falls Intervall noch offen + if interval.EndTime.Valid { + endTime = interval.EndTime.Time + } + // Stelle sicher, dass das Ende nicht über den Abfragezeitraum hinausgeht + if endTime.After(end) { + endTime = end + } + // Stelle sicher, dass der Start nicht vor dem Abfragezeitraum liegt + startTime := interval.StartTime + if startTime.Before(start) { + startTime = start + } + + duration := endTime.Sub(startTime) + if duration < 0 { + duration = 0 + } // Sicherstellen, dass die Dauer nicht negativ ist + + tags[interval.Tag] += duration + + if interval.Tag == "work" { + totalWork += duration + } else if interval.Tag == "break" { + totalBreak += duration + } + } + + fmt.Println("--- Totals ---") + fmt.Printf("Work: %s\n", formatDuration(totalWork)) + fmt.Printf("Break: %s\n", formatDuration(totalBreak)) + fmt.Println("--- Tags ---") + for tag, duration := range tags { + fmt.Printf("%-10s: %s\n", tag, formatDuration(duration)) + } + + return nil +} + +// -- ExportSummary und Hilfsfunktionen (angepasst für SQLite Daten) -- + +// Behalte DailySummary und ExcelEntry Strukturen bei oder passe sie an +// type DailySummary struct { ... } +// type ExcelEntry struct { ... } + +func (t *SQLiteTimeTracker) ExportSummary(name string) error { + fmt.Println("Export Timetable") + // Hole Daten für das ganze Jahr (oder mache Zeitraum konfigurierbar) + start, end := calculateDateRange(":year") + intervals, err := t.getIntervals(start, end) + if err != nil { + return fmt.Errorf("cannot get intervals for export: %w", err) + } + + if len(intervals) == 0 { + fmt.Println("No data to export for the year.") + return nil + } + + // Verarbeitung der Intervalle zu Tageszusammenfassungen + dailySummaries := make(map[string]*DailySummary) // Key: "YYYY-MM-DD" + + for _, interval := range intervals { + currentDay := interval.StartTime.Format("2006-01-02") + daySummary, exists := dailySummaries[currentDay] + if !exists { + daySummary = &DailySummary{ + Date: currentDay, + Day: interval.StartTime.Format("Mon"), // Wochentag + } + dailySummaries[currentDay] = daySummary + } + + // Berechne Dauer nur für abgeschlossene Intervalle innerhalb des Tages + // Offene Intervalle werden für den Export ignoriert oder bis 'now' gerechnet? + // Hier ignoriert für Einfachheit, außer sie enden am selben Tag. + var intervalEnd time.Time + if interval.EndTime.Valid { + intervalEnd = interval.EndTime.Time + // Stelle sicher, dass das Intervall am selben Tag endet + if intervalEnd.Format("2006-01-02") != currentDay { + // Wenn es über Mitternacht geht, schneide es am Ende des Tages ab + dayEnd := interval.StartTime.Truncate(24*time.Hour).AddDate(0, 0, 1).Add(-time.Nanosecond) + if intervalEnd.After(dayEnd) { + intervalEnd = dayEnd + } + } + + } else { + // Aktuell laufendes Intervall - nicht für Export berücksichtigen oder bis jetzt? + // Entscheide, wie du offene Intervalle behandeln willst. Hier ignoriert. + continue + } + + duration := intervalEnd.Sub(interval.StartTime) + if duration < 0 { + duration = 0 + } + + switch strings.ToLower(interval.Tag) { + case "work": + // Finde frühesten Start und spätesten Endzeitpunkt für "work" an diesem Tag + if daySummary.WorkStart == "" || interval.StartTime.Format("15:04:05") < daySummary.WorkStart { + daySummary.WorkStart = interval.StartTime.Format("15:04:05") + } + // Nimm das späteste Ende *aller* Arbeitsintervalle des Tages + if intervalEnd.Format("15:04:05") > daySummary.WorkEnd { + daySummary.WorkEnd = intervalEnd.Format("15:04:05") + } + // Die tatsächliche Arbeitszeit wird später in Excel berechnet + // Alternativ: Addiere hier die Dauer zu einer `WorkDuration` hinzu + case "break": + daySummary.BreakDuration += duration + // Handle andere Tags wie "uni", "free", etc. + // Du könntest Ganztags-Tags speichern oder sie als spezielles Intervall behandeln + default: + if daySummary.Tag == "" { // Nur den ersten speziellen Tag nehmen? + daySummary.Tag = interval.Tag + } + } + } + + // Konvertiere zu ExcelEntry + var excelEntries []ExcelEntry + for _, summary := range dailySummaries { + entry := ExcelEntry{ + Date: summary.Date, + Day: summary.Day, + WorkStart: summary.WorkStart, + WorkEnd: summary.WorkEnd, + BreakDuration: formatDuration(summary.BreakDuration), // Formatieren für Excel + Tag: summary.Tag, + } + excelEntries = append(excelEntries, entry) + } + + // Sortiere nach Datum vor dem Schreiben + sort.Slice(excelEntries, func(i, j int) bool { + dateI, _ := time.Parse("2006-01-02", excelEntries[i].Date) + dateJ, _ := time.Parse("2006-01-02", excelEntries[j].Date) + return dateI.Before(dateJ) + }) + + // Schreibe die Excel-Datei (reuse writeExcelSheet, ggf. anpassen) + return writeExcelSheet(excelEntries, name) +} + +// --- Hilfsfunktionen (parseDuration wird nicht mehr benötigt, formatDuration schon) --- + +// formatDuration bleibt gleich +func formatDuration(d time.Duration) string { + d = d.Round(time.Second) // Runde auf Sekunden für Konsistenz + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + d -= m * time.Minute + s := d / time.Second + return fmt.Sprintf("%d:%02d:%02d", h, m, s) +} + +// writeExcelSheet bleibt größtenteils gleich, muss aber ExcelEntry erhalten +// Stelle sicher, dass die Logik zur Berechnung von Brutto/Netto/Saldo in Excel korrekt ist +// oder passe die `ExcelEntry`-Struktur an, um vorkalkulierte Werte zu übergeben. +// Die Funktion muss `excelEntries []ExcelEntry` als Argument nehmen. +// func writeExcelSheet(entries []ExcelEntry, name string) error { ... } + +// (Behalte die DailySummary und ExcelEntry structs wie vorher bei) +type DailySummary struct { + Date string + Day string + WorkStart string // Frühester Start + WorkEnd string // Spätestes Ende + BreakDuration time.Duration // Summe der Pausen + Tag string // Für Ganztags-Tags wie Urlaub, Feiertag +} + +type ExcelEntry struct { + Date string + Day string + WorkStart string + WorkEnd string + BreakDuration string // Als "HH:MM:SS" String für Excel + Tag string +} + +// (Füge hier die `writeExcelSheet`-Funktion aus deiner timewarrior.go ein, +// +// stelle sicher, dass sie `excelEntries []ExcelEntry` akzeptiert) +func writeExcelSheet(entries []ExcelEntry, name string) error { + // ... (Rest der Funktion wie in timewarrior.go, aber mit `entries`) + // Stelle sicher, dass Zeitwerte korrekt als Excel-Zeit/Datum formatiert werden. + // Die Berechnung von Brutto/Netto/Saldo sollte weiterhin über Excel-Formeln erfolgen. + + sheetName := fmt.Sprint(time.Now().Year()) + f := excelize.NewFile() + defer func() { + if err := f.Close(); err != nil { + fmt.Println(err) + } + }() + + index, err := f.NewSheet(sheetName) + if err != nil { + return err + } + + // Header setzen (wie zuvor) + f.SetCellValue(sheetName, "B1", "Arbeitszeiten "+sheetName) + // ... (alle anderen Header-Zellen) + f.SetCellValue(sheetName, "B3", "Datum") + f.SetCellValue(sheetName, "D3", "Arbeitszeit") + f.SetCellValue(sheetName, "G3", "Summe") + f.SetCellValue(sheetName, "I3", "Pause") + f.SetCellValue(sheetName, "J3", "Summe") + f.SetCellValue(sheetName, "K3", "Soll") + f.SetCellValue(sheetName, "L3", "Saldo") + f.SetCellValue(sheetName, "N3", "Saldo") + f.SetCellValue(sheetName, "D4", "von") + f.SetCellValue(sheetName, "E4", "bis") + f.SetCellValue(sheetName, "G4", "brutto") + f.SetCellValue(sheetName, "J4", "netto") + f.SetCellValue(sheetName, "L4", "Tag") + f.SetCellValue(sheetName, "N4", "total") + + // Zeitformat für Excel + strStyle := "[h]:mm;@" // Format für Dauer/Zeit + timeStyle, err := f.NewStyle(&excelize.Style{ + CustomNumFmt: &strStyle, + Alignment: &excelize.Alignment{Horizontal: "right"}, // Zeiten rechtsbündig + }) + if err != nil { + return err + } + // Datumsformat für Excel + dateStyle, err := f.NewStyle(&excelize.Style{NumFmt: 14}) // Format "dd/mm/yyyy" oder ähnlich + if err != nil { + return err + } + + for num, entry := range entries { + row := fmt.Sprint(num + 6) // Start in Zeile 6 + + // Datum setzen + dateValue, _ := time.Parse("2006-01-02", entry.Date) + f.SetCellValue(sheetName, "B"+row, dateValue) + f.SetCellStyle(sheetName, "B"+row, "B"+row, dateStyle) + + if entry.Tag == "" && entry.WorkStart != "" && entry.WorkEnd != "" { // Nur wenn gearbeitet wurde + // Arbeitszeiten setzen (als Excel-Zeitwerte) + startTime, _ := time.Parse("15:04:05", entry.WorkStart) + endTime, _ := time.Parse("15:04:05", entry.WorkEnd) + // Excel speichert Zeiten als Bruchteil eines Tages + 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) + + // Wenn Endzeit vor Startzeit liegt (über Mitternacht gearbeitet - unwahrscheinlich für Tageszusammenfassung) + if endExcelTime < startExcelTime { + endExcelTime += 1.0 // Füge einen Tag hinzu + } + + f.SetCellValue(sheetName, "D"+row, startExcelTime) + f.SetCellStyle(sheetName, "D"+row, "D"+row, timeStyle) + f.SetCellValue(sheetName, "E"+row, endExcelTime) + f.SetCellStyle(sheetName, "E"+row, "E"+row, timeStyle) + + // Formeln für Berechnungen + f.SetCellFormula(sheetName, "G"+row, fmt.Sprintf("IF(E%[1]d