diff options
Diffstat (limited to 'internal/worktime')
| -rw-r--r-- | internal/worktime/import.go | 153 | ||||
| -rw-r--r-- | internal/worktime/import_test.go | 76 |
2 files changed, 229 insertions, 0 deletions
diff --git a/internal/worktime/import.go b/internal/worktime/import.go new file mode 100644 index 0000000..d84becd --- /dev/null +++ b/internal/worktime/import.go @@ -0,0 +1,153 @@ +package worktime + +import ( + "bufio" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "codeberg.org/snonux/timr/internal/timefmt" +) + +const importSecondsPerHour = 3600 + +// ImportLine is a parsed report line with hourly values by category. +type ImportLine struct { + When time.Time + WorkHours float64 + LunchHours float64 + OffHours float64 +} + +// ImportReport imports report-format lines into the worktime database. +func ImportReport(dbDir, hostname string, report io.Reader) (imported int, err error) { + scanner := bufio.NewScanner(report) + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, "lunch:") { + continue + } + + parsed, parseErr := ParseImportLine(line) + if parseErr != nil { + return imported, parseErr + } + + workSeconds := hoursToSeconds(parsed.WorkHours) + lunchSeconds := hoursToSeconds(parsed.LunchHours) + offSeconds := hoursToSeconds(parsed.OffHours) + + if lunchSeconds > 0 { + workSeconds += lunchSeconds + } + + if workSeconds > 0 { + if _, addErr := Add(dbDir, hostname, "work", time.Duration(workSeconds)*time.Second, parsed.When, ""); addErr != nil { + return imported, addErr + } + imported++ + } + if lunchSeconds > 0 { + if _, addErr := Add(dbDir, hostname, "lunch", time.Duration(lunchSeconds)*time.Second, parsed.When, ""); addErr != nil { + return imported, addErr + } + imported++ + } + if offSeconds > 0 { + if _, addErr := Add(dbDir, hostname, "off", time.Duration(offSeconds)*time.Second, parsed.When, ""); addErr != nil { + return imported, addErr + } + imported++ + } + } + + if scanErr := scanner.Err(); scanErr != nil { + return imported, scanErr + } + + return imported, nil +} + +// ParseImportLine parses one report-format day line. +func ParseImportLine(line string) (ImportLine, error) { + fields := strings.Fields(line) + if len(fields) < 7 { + return ImportLine{}, fmt.Errorf("unsupported import line: %q", line) + } + + dateToken := strings.TrimSuffix(fields[1], ":") + workToken := fields[2] + lunchToken := fields[4] + offToken := fields[6] + + when, err := ParseImportDate(dateToken) + if err != nil { + return ImportLine{}, err + } + + workHours, err := parseHourToken(workToken) + if err != nil { + return ImportLine{}, err + } + lunchHours, err := parseHourToken(lunchToken) + if err != nil { + return ImportLine{}, err + } + offHours, err := parseHourToken(offToken) + if err != nil { + return ImportLine{}, err + } + + return ImportLine{ + When: when, + WorkHours: workHours, + LunchHours: lunchHours, + OffHours: offHours, + }, nil +} + +// ParseImportDate parses date values used by work report imports. +func ParseImportDate(token string) (time.Time, error) { + trimmed := strings.TrimSpace(token) + if trimmed == "" { + return time.Time{}, errors.New("import date is empty") + } + + parsed, parseErr := timefmt.Parse(trimmed) + if parseErr == nil { + return parsed, nil + } + + layouts := []string{ + "02.01.2006", + "20060102", + } + for _, layout := range layouts { + if parsed, err := time.ParseInLocation(layout, trimmed, time.Local); err == nil { + return parsed, nil + } + } + + return time.Time{}, fmt.Errorf("unsupported import date %q: %w", token, parseErr) +} + +func parseHourToken(token string) (float64, error) { + clean := strings.TrimSpace(token) + if idx := strings.Index(clean, ":"); idx >= 0 { + clean = clean[idx+1:] + } + clean = strings.TrimSuffix(clean, "h") + + value, err := strconv.ParseFloat(clean, 64) + if err != nil { + return 0, fmt.Errorf("parse hour token %q: %w", token, err) + } + return value, nil +} + +func hoursToSeconds(hours float64) int64 { + return int64(hours * importSecondsPerHour) +} diff --git a/internal/worktime/import_test.go b/internal/worktime/import_test.go new file mode 100644 index 0000000..0a317e0 --- /dev/null +++ b/internal/worktime/import_test.go @@ -0,0 +1,76 @@ +package worktime + +import ( + "strings" + "testing" + "time" +) + +func TestParseImportDateFallbackLayout(t *testing.T) { + parsed, err := ParseImportDate("06.01.2026") + if err != nil { + t.Fatalf("ParseImportDate() error = %v", err) + } + + expected := time.Date(2026, 1, 6, 0, 0, 0, 0, time.Local) + if parsed.Year() != expected.Year() || parsed.Month() != expected.Month() || parsed.Day() != expected.Day() { + t.Fatalf("parsed date = %v, want %v", parsed, expected) + } +} + +func TestParseImportDateIncludesUnderlyingParseError(t *testing.T) { + _, err := ParseImportDate("not-a-date") + if err == nil { + t.Fatal("ParseImportDate() error = nil, want error") + } + if !strings.Contains(err.Error(), "unsupported import date") { + t.Fatalf("ParseImportDate() error = %v, want unsupported import date context", err) + } +} + +func TestParseImportLine(t *testing.T) { + line := "Mon 06.01.2026: +8.00h lunch: +0.50h off: +1.00h" + parsed, err := ParseImportLine(line) + if err != nil { + t.Fatalf("ParseImportLine() error = %v", err) + } + + if parsed.WorkHours != 8 { + t.Fatalf("WorkHours = %v, want 8", parsed.WorkHours) + } + if parsed.LunchHours != 0.5 { + t.Fatalf("LunchHours = %v, want 0.5", parsed.LunchHours) + } + if parsed.OffHours != 1 { + t.Fatalf("OffHours = %v, want 1", parsed.OffHours) + } +} + +func TestImportReport(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + report := strings.NewReader("Mon 06.01.2026: +8.00h lunch: +0.50h off: +0.00h\n") + + imported, err := ImportReport(dbDir, host, report) + if err != nil { + t.Fatalf("ImportReport() error = %v", err) + } + if imported != 2 { + t.Fatalf("imported = %d, want 2", imported) + } + + db, err := LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + entries := db.Entries[host] + if len(entries) != 2 { + t.Fatalf("entries len = %d, want 2", len(entries)) + } + if entries[0].What != "work" || entries[0].Value != 30600 { + t.Fatalf("work entry = %+v, want 8.5h in seconds", entries[0]) + } + if entries[1].What != "lunch" || entries[1].Value != 1800 { + t.Fatalf("lunch entry = %+v, want 0.5h in seconds", entries[1]) + } +} |
