package main import ( "database/sql" "fmt" "log" "os" "path/filepath" "strings" "time" "golang.org/x/text/cases" "golang.org/x/text/language" _ "modernc.org/sqlite" ) const ( TagWork = "work" TagBreak = "break" ) type TimeEntry struct { ID int64 Tag string StartTime time.Time EndTime sql.NullTime } type TimeStore struct { db *sql.DB dbPath string } func NewTimeStore(cfg Config) (*TimeStore, error) { dbPath, err := ensureDatabasePath(cfg) if err != nil { return nil, fmt.Errorf("could not determine database path: %w", err) } log.Printf("INFO: Using database at: %s", dbPath) db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath)) if err != nil { return nil, fmt.Errorf("failed to open database '%s': %w", dbPath, err) } if err = db.Ping(); err != nil { db.Close() return nil, fmt.Errorf("failed to connect to database '%s': %w", dbPath, err) } createTableSQL := ` CREATE TABLE IF NOT EXISTS time_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, tag TEXT NOT NULL CHECK(tag <> ''), -- Stelle sicher, dass Tag nicht leer ist start_time DATETIME NOT NULL, end_time DATETIME NULL, -- Optional: Stelle sicher, dass nur ein Eintrag NULL end_time haben kann (falls DB unterstützt) -- UNIQUE (end_time) WHERE end_time IS NULL -- SQLite unterstützt dies nicht direkt CHECK (end_time IS NULL OR end_time >= start_time) -- Endzeit muss nach Startzeit liegen );` if _, err = db.Exec(createTableSQL); err != nil { db.Close() return nil, fmt.Errorf("failed to create table 'time_entries': %w", err) } createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries (start_time);` if _, err = db.Exec(createIndexSQL); err != nil { log.Printf("WARN: Failed to create index on start_time: %v", err) } return &TimeStore{db: db, dbPath: dbPath}, nil } func ensureDatabasePath(_ Config) (string, error) { configDir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("could not get user config dir: %w", err) } workConfigDir := filepath.Join(configDir, "work") dbPath := filepath.Join(workConfigDir, "worktime.sqlite") if err := os.MkdirAll(workConfigDir, 0750); err != nil { return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err) } return dbPath, nil } func (ts *TimeStore) Close() error { if ts.db != nil { log.Printf("INFO: Closing database connection to %s", ts.dbPath) return ts.db.Close() } return nil } func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) { query := `UPDATE time_entries SET end_time = ? WHERE end_time IS NULL;` result, err := ts.db.Exec(query, now) if err != nil { return false, fmt.Errorf("failed to execute stop current entry query: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return false, fmt.Errorf("failed to get affected rows after stopping entry: %w", err) } if rowsAffected > 1 { log.Printf("WARN: Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected) } return rowsAffected > 0, nil } func (ts *TimeStore) StartTracking(tag string) error { if tag == "" { return fmt.Errorf("cannot start tracking with an empty tag") } now := time.Now() stopped, err := ts.stopCurrentEntry(now) if err != nil { return err } if stopped { log.Println("INFO: Stopped previous time entry.") } query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);` _, err = ts.db.Exec(query, tag, now) if err != nil { return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err) } log.Printf("INFO: Started tracking: %s at %s", tag, now.Format(time.RFC3339)) return nil } func (ts *TimeStore) StopTracking() error { now := time.Now() stopped, err := ts.stopCurrentEntry(now) if err != nil { return err } if stopped { log.Printf("INFO: Stopped tracking at %s", now.Format(time.RFC3339)) } else { log.Println("INFO: No active time entry found to stop.") } return nil } func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error) { if start.IsZero() || end.IsZero() || end.Before(start) { return nil, fmt.Errorf("invalid time range: start=%v, end=%v", start, end) } query := ` SELECT id, tag, start_time, end_time FROM time_entries WHERE start_time >= ? AND start_time < ? ORDER BY start_time ASC;` rows, err := ts.db.Query(query, start, end) if err != nil { return nil, fmt.Errorf("failed to query entries in range [%v, %v): %w", start, end, 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 nil, fmt.Errorf("failed to scan entry row: %w", err) } entries = append(entries, entry) } if err = rows.Err(); err != nil { return nil, fmt.Errorf("error during row iteration: %w", err) } return entries, nil } func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration, error) { start, end := getTimeRangeFromPeriod(period) if start.IsZero() { return nil, fmt.Errorf("invalid period string: '%s'", period) } query := ` SELECT id, tag, start_time, end_time FROM time_entries WHERE (end_time IS NULL OR end_time > ?) -- Endet nach dem Start des Zeitraums AND start_time < ? -- Beginnt vor dem Ende des Zeitraums ORDER BY start_time ASC;` rows, err := ts.db.Query(query, start, end) if err != nil { return nil, fmt.Errorf("failed to query overlapping entries for range [%v, %v): %w", start, end, err) } defer rows.Close() summary := make(map[string]time.Duration) now := time.Now() for rows.Next() { var entry TimeEntry if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil { return nil, fmt.Errorf("failed to scan overlapping entry row: %w", err) } effectiveStart := entry.StartTime if effectiveStart.Before(start) { effectiveStart = start } effectiveEnd := entry.EndTime.Time if !entry.EndTime.Valid { effectiveEnd = now } if effectiveEnd.After(end) { effectiveEnd = end } if effectiveEnd.After(effectiveStart) { duration := effectiveEnd.Sub(effectiveStart) summary[entry.Tag] += duration } } if err = rows.Err(); err != nil { return nil, fmt.Errorf("error during overlapping row iteration: %w", err) } return summary, nil } func getTimeRangeFromPeriod(period string) (time.Time, time.Time) { now := time.Now() year, month, day := now.Date() loc := now.Location() normalizedPeriod := strings.ToLower(strings.TrimPrefix(period, ":")) switch normalizedPeriod { case "week": weekday := now.Weekday() daysToMonday := time.Duration(weekday - time.Monday) if weekday == time.Sunday { daysToMonday = 6 } start := time.Date(year, month, day, 0, 0, 0, 0, loc).Add(-daysToMonday * 24 * time.Hour) end := start.Add(7 * 24 * time.Hour) return start, end case "month": start := time.Date(year, month, 1, 0, 0, 0, 0, loc) end := start.AddDate(0, 1, 0) return start, end case "year": start := time.Date(year, 1, 1, 0, 0, 0, 0, loc) end := start.AddDate(1, 0, 0) return start, end case "day", "today": start := time.Date(year, month, day, 0, 0, 0, 0, loc) end := start.AddDate(0, 0, 1) return start, end default: if t, err := time.ParseInLocation("2006-01-02", period, loc); err == nil { start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) end := start.AddDate(0, 0, 1) return start, end } log.Printf("WARN: Unrecognized period string '%s'. Cannot calculate time range.", period) return time.Time{}, time.Time{} } } func (ts *TimeStore) ShowSummary(period string) error { summary, err := ts.CalculateSummary(period) if err != nil { return fmt.Errorf("error calculating summary for '%s': %w", period, err) } start, _ := getTimeRangeFromPeriod(period) titlePeriod := period if !start.IsZero() { _, end := getTimeRangeFromPeriod(period) if period == ":day" || period == "today" { titlePeriod = fmt.Sprintf("Today (%s)", start.Format("2006-01-02")) } else if period == ":week" { titlePeriod = fmt.Sprintf("Week starting %s", start.Format("Mon, 2006-01-02")) } else if period == ":month" { titlePeriod = fmt.Sprintf("Month %s", start.Format("January 2006")) } else if period == ":year" { titlePeriod = fmt.Sprintf("Year %d", start.Year()) } else if _, err := time.Parse("2006-01-02", period); err == nil { titlePeriod = fmt.Sprintf("Day %s", start.Format("2006-01-02")) } else { titlePeriod = fmt.Sprintf("Period '%s' (%s to %s)", period, start.Format("2006-01-02"), end.Format("2006-01-02")) } } fmt.Printf("\nTime Summary for %s\n", titlePeriod) if len(summary) == 0 { fmt.Println(" No recorded time entries for this period.") return nil } tags := make([]string, 0, len(summary)) for tag := range summary { tags = append(tags, tag) } titleCaser := cases.Title(language.English) totalDuration := time.Duration(0) fmt.Println("------------------------------") for _, tag := range tags { duration := summary[tag] fmt.Printf(" %-12s: %s\n", titleCaser.String(tag), formatDuration(duration)) totalDuration += duration } fmt.Println("------------------------------") fmt.Printf(" Total : %s\n\n", formatDuration(totalDuration)) return nil } func (ts *TimeStore) ExportSummary(filename string) error { log.Printf("INFO: 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) log.Printf("INFO: 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 < ? -- Beginnt vor Anfang des nächsten Jahres AND (end_time IS NULL OR end_time > ?) -- Endet nach Anfang des Jahres ORDER BY start_time ASC;` rows, err := ts.db.Query(query, yearEnd, yearStart) if err != nil { return fmt.Errorf("failed to query entries for year export [%v, %v): %w", yearStart, yearEnd, 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 (ID: %d) for export: %w", entry.ID, err) } entries = append(entries, entry) } if err = rows.Err(); err != nil { return fmt.Errorf("error during export row iteration: %w", err) } log.Printf("INFO: Found %d potentially relevant time entries for year %d.", len(entries), currentYear) 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 { log.Println("WARN: No daily summaries generated for the export period.") fmt.Println("No data available to generate the export for the specified period.") return nil } log.Printf("INFO: Generated %d daily entries for the Excel export.", len(excelEntries)) if err := writeExcelSheet(excelEntries, filename); err != nil { // Aufruf der geänderten Funktion return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err) } log.Printf("INFO: Successfully exported timetable to %s", filename) fmt.Printf("Successfully exported timetable to %s\n", filename) return nil } func (ts *TimeStore) LogFullDay(tag string, date time.Time) error { if tag == "" { return fmt.Errorf("cannot log full day with an empty tag") } tag = strings.ToLower(tag) // Stelle sicher, dass der Tag klein geschrieben ist location := date.Location() // Verwende die Zeitzone des übergebenen Datums // Berechne Start (00:00:00 des Tages) und Ende (00:00:00 des nächsten Tages) dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) dayEnd := dayStart.Add(24 * time.Hour) dayStr := dayStart.Format("2006-01-02") log.Printf("INFO: Attempting to log '%s' for the full day %s", tag, dayStr) // 1. Stoppe den aktuell laufenden Timer (falls vorhanden) // Wir verwenden dayStart als Zeitpunkt für das Stoppen, um Konsistenz zu wahren stopped, err := ts.stopCurrentEntry(dayStart) if err != nil { // Nur loggen, weitermachen. Der Nutzer will diesen Tag ja explizit setzen. log.Printf("WARN: Failed to stop current entry before logging full day '%s': %v", tag, err) } else if stopped { log.Printf("INFO: Stopped active timer before logging '%s' for %s.", tag, dayStr) } tx, err := ts.db.Begin() if err != nil { return fmt.Errorf("could not begin transaction to log full day: %w", err) } defer tx.Rollback() // Stellt sicher, dass bei Fehlern nichts gespeichert wird query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);` stmt, err := tx.Prepare(query) if err != nil { return fmt.Errorf("could not prepare statement to log full day: %w", err) } defer stmt.Close() _, err = stmt.Exec(tag, dayStart, dayEnd) if err != nil { // Spezifischere Fehlermeldung, falls es UNIQUE Constraints gäbe return fmt.Errorf("failed to insert full-day entry for tag '%s' on %s: %w", tag, dayStr, err) } // Transaktion erfolgreich abschließen if err = tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction for full-day entry: %w", err) } titleCaser := cases.Title(language.English) log.Printf("INFO: Successfully logged full day entry: Tag='%s', Start='%s', End='%s'", tag, dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339)) fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStr) // Benutzerfeedback return nil }