summaryrefslogtreecommitdiff
path: root/internal/worktime
diff options
context:
space:
mode:
Diffstat (limited to 'internal/worktime')
-rw-r--r--internal/worktime/import.go153
-rw-r--r--internal/worktime/import_test.go76
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])
+ }
+}