diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-03 22:43:03 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-03 22:43:03 +0200 |
| commit | eb14c8808e353ee9b509fbce00c52caa640761b3 (patch) | |
| tree | d4d434367269ac2bfd75814946ac3a9ddf0f28bf /internal/worktime | |
| parent | ac0a71de2a4a8894de849d10bbc4de962a5c0c2b (diff) | |
Task 354: add worktime report engine
Diffstat (limited to 'internal/worktime')
| -rw-r--r-- | internal/worktime/report.go | 402 | ||||
| -rw-r--r-- | internal/worktime/report_test.go | 164 |
2 files changed, 566 insertions, 0 deletions
diff --git a/internal/worktime/report.go b/internal/worktime/report.go new file mode 100644 index 0000000..e46cd4d --- /dev/null +++ b/internal/worktime/report.go @@ -0,0 +1,402 @@ +package worktime + +import ( + "fmt" + "math" + "sort" + "strings" + "time" + + "codeberg.org/snonux/timr/internal/config" +) + +const secondsPerHour = int64(3600) + +const ( + colorReset = "\033[0m" + colorCyan = "\033[36m" + colorGreen = "\033[32m" + colorRed = "\033[31m" +) + +// DayReport contains report data for one calendar day. +type DayReport struct { + Epoch int64 + DayLabel string + Marker string + Values map[string]int64 + RequiredSeconds int64 +} + +// WeekReport contains report data for one ISO week. +type WeekReport struct { + WeekLabel string + Days []DayReport + Values map[string]int64 + RequiredSeconds int64 + WeeklyBalanceSeconds int64 + CumulativeBalanceSeconds int64 + BufferSeconds int64 +} + +type dayAccumulator struct { + epoch int64 + values map[string]int64 +} + +type weekAccumulator struct { + weekLabel string + days []DayReport + values map[string]int64 +} + +// BuildReport generates weekly reports from merged worktime entries. +func BuildReport(entries []Entry, cfg config.Config) ([]WeekReport, error) { + if len(entries) == 0 { + return []WeekReport{}, nil + } + + cfg = reportDefaults(cfg) + sorted := make([]Entry, len(entries)) + copy(sorted, entries) + sortEntries(sorted) + + bufferFor := stringSet(cfg.BufferFor) + minusFor := stringSet(cfg.MinusFor) + weekendDays := stringSet(cfg.WeekendDays) + plusFor := stringSet(cfg.PlusFor) + + login := map[string]Entry{} + var totalBuffer int64 + var cumulativeBalance int64 + var reports []WeekReport + + currentDay := newDayAccumulator() + currentWeek := newWeekAccumulator() + + prevDayKey := "" + prevWeekKey := "" + + for _, entry := range sorted { + entryDayKey := dayKey(entry.Epoch) + entryWeekKey := isoWeekKey(entry.Epoch) + + if prevDayKey == "" { + prevDayKey = entryDayKey + } + if prevWeekKey == "" { + prevWeekKey = entryWeekKey + currentWeek.weekLabel = weekLabel(entry.Epoch) + } + + if entryDayKey != prevDayKey { + finalizeDayIntoWeek(¤tWeek, currentDay, minusFor, weekendDays) + currentDay = newDayAccumulator() + prevDayKey = entryDayKey + } + + if entryWeekKey != prevWeekKey { + weekReport := finalizeWeek(currentWeek, cfg, plusFor, minusFor, totalBuffer, &cumulativeBalance) + reports = append(reports, weekReport) + currentWeek = newWeekAccumulator() + currentWeek.weekLabel = weekLabel(entry.Epoch) + prevWeekKey = entryWeekKey + } + + category := normalizeCategory(entry.What) + if currentDay.epoch == 0 { + currentDay.epoch = entry.Epoch + } + if _, ok := currentDay.values[category]; !ok { + currentDay.values[category] = 0 + } + + action := strings.ToLower(strings.TrimSpace(entry.Action)) + switch action { + case actionAdd: + currentDay.values[category] += entry.Value + if _, ok := bufferFor[category]; ok { + totalBuffer += entry.Value + } + case actionLogin: + if _, ok := login[category]; ok { + return nil, fmt.Errorf("already logged in for %q at epoch %d", category, entry.Epoch) + } + login[category] = entry + case actionLogout: + startEntry, ok := login[category] + if !ok { + return nil, fmt.Errorf("logout without login for %q at epoch %d", category, entry.Epoch) + } + currentDay.values[category] += entry.Epoch - startEntry.Epoch + delete(login, category) + default: + return nil, fmt.Errorf("unknown action %q at epoch %d", entry.Action, entry.Epoch) + } + } + + finalizeDayIntoWeek(¤tWeek, currentDay, minusFor, weekendDays) + weekReport := finalizeWeek(currentWeek, cfg, plusFor, minusFor, totalBuffer, &cumulativeBalance) + reports = append(reports, weekReport) + + return reports, nil +} + +// FormatReport renders week/day reports as text. Colors can be toggled. +func FormatReport(weeks []WeekReport, verbose, color bool) string { + var out strings.Builder + + for _, week := range weeks { + for _, day := range week.Days { + out.WriteString(" ") + out.WriteString(day.Marker) + out.WriteString(" ") + out.WriteString(day.DayLabel) + out.WriteString(":") + out.WriteString(formatData(day.Values, 0, day.Epoch, verbose, color)) + out.WriteString("\n") + } + + out.WriteString("================================================\n") + weekValues := cloneValueMap(week.Values) + weekValues["balance"] = week.CumulativeBalanceSeconds + out.WriteString(formatData(weekValues, week.BufferSeconds, 0, false, color)) + out.WriteString("\n\n\n") + } + + return out.String() +} + +func finalizeDayIntoWeek(week *weekAccumulator, day dayAccumulator, minusFor map[string]struct{}, weekendDays map[string]struct{}) { + if day.epoch == 0 { + return + } + + for key, value := range day.values { + week.values[key] += value + } + + dayValues := cloneValueMap(day.values) + for category := range minusFor { + if value, ok := dayValues[category]; ok { + dayValues["work"] -= value + } + } + + marker := dayMarker(day.epoch, day.values, weekendDays) + required := int64(8) * secondsPerHour + if marker == "*" { + required = 0 + } + + week.days = append(week.days, DayReport{ + Epoch: day.epoch, + DayLabel: dayLabel(day.epoch), + Marker: marker, + Values: dayValues, + RequiredSeconds: required, + }) +} + +func finalizeWeek( + week weekAccumulator, + cfg config.Config, + plusFor map[string]struct{}, + minusFor map[string]struct{}, + totalBuffer int64, + cumulativeBalance *int64, +) WeekReport { + values := cloneValueMap(week.values) + + required := int64(math.Round(cfg.WeekWorkHours * float64(secondsPerHour))) + for category := range plusFor { + required -= values[category] + } + + work := values["work"] + for category := range minusFor { + work -= values[category] + } + values["work"] = work + + weeklyBalance := work - required + *cumulativeBalance += weeklyBalance + + return WeekReport{ + WeekLabel: week.weekLabel, + Days: week.days, + Values: values, + RequiredSeconds: required, + WeeklyBalanceSeconds: weeklyBalance, + CumulativeBalanceSeconds: *cumulativeBalance, + BufferSeconds: totalBuffer, + } +} + +func formatData(values map[string]int64, bufferSeconds int64, epoch int64, verbose, color bool) string { + keys := []string{"balance", "work", "lunch", "off", "sick", "bank", "pet", "selfdevelopment"} + var out strings.Builder + + for _, key := range keys { + value, hasValue := values[key] + if !hasValue && key != "work" { + continue + } + if !hasValue { + value = 0 + } + if value == 0 && key != "work" { + continue + } + + out.WriteString(" ") + out.WriteString(colorizeLabel(key, color)) + out.WriteString(":") + out.WriteString(colorizeValue(formatHours(value), value, color)) + out.WriteString("h") + } + + if bufferSeconds != 0 { + out.WriteString(" ") + out.WriteString(colorizeLabel("buffer", color)) + out.WriteString(":") + out.WriteString(colorizeValue(formatHours(bufferSeconds), bufferSeconds, color)) + out.WriteString("h") + } + + if verbose && epoch > 0 { + out.WriteString(fmt.Sprintf(" epoch:%d(%s)", epoch, time.Unix(epoch, 0))) + } + + return out.String() +} + +func dayMarker(epoch int64, values map[string]int64, weekendDays map[string]struct{}) string { + if values["off"] >= 8*secondsPerHour { + return "*" + } + if values["bank"] >= 8*secondsPerHour { + return "*" + } + + weekday := time.Unix(epoch, 0).Format("Mon") + if _, ok := weekendDays[weekday]; ok { + return "*" + } + + return " " +} + +func dayLabel(epoch int64) string { + t := time.Unix(epoch, 0) + _, week := t.ISOWeek() + return fmt.Sprintf("%s %s %02d", t.Format("Mon"), t.Format("20060102"), week) +} + +func dayKey(epoch int64) string { + t := time.Unix(epoch, 0) + return t.Format("2006-01-02") +} + +func weekLabel(epoch int64) string { + _, week := time.Unix(epoch, 0).ISOWeek() + return fmt.Sprintf("%02d", week) +} + +func isoWeekKey(epoch int64) string { + year, week := time.Unix(epoch, 0).ISOWeek() + return fmt.Sprintf("%d-%02d", year, week) +} + +func reportDefaults(cfg config.Config) config.Config { + defaults := config.Default() + + if cfg.WeekWorkHours == 0 { + cfg.WeekWorkHours = defaults.WeekWorkHours + } + if cfg.PlusFor == nil { + cfg.PlusFor = defaults.PlusFor + } + if cfg.WeekendDays == nil { + cfg.WeekendDays = defaults.WeekendDays + } + if cfg.MinusFor == nil { + cfg.MinusFor = defaults.MinusFor + } + if cfg.BufferFor == nil { + cfg.BufferFor = defaults.BufferFor + } + + return cfg +} + +func formatHours(seconds int64) string { + return fmt.Sprintf("%0.2f", float64(seconds)/float64(secondsPerHour)) +} + +func colorizeLabel(label string, color bool) string { + if !color { + return label + } + return colorCyan + label + colorReset +} + +func colorizeValue(value string, raw int64, color bool) string { + if !color { + return value + } + + switch { + case raw < 0: + return colorRed + value + colorReset + case raw > 0: + return colorGreen + value + colorReset + default: + return value + } +} + +func cloneValueMap(values map[string]int64) map[string]int64 { + cloned := make(map[string]int64, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + +func stringSet(items []string) map[string]struct{} { + set := make(map[string]struct{}, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + set[trimmed] = struct{}{} + } + return set +} + +func newDayAccumulator() dayAccumulator { + return dayAccumulator{ + values: map[string]int64{}, + } +} + +func newWeekAccumulator() weekAccumulator { + return weekAccumulator{ + values: map[string]int64{}, + days: []DayReport{}, + } +} + +func sortedWeekReports(reports []WeekReport) { + sort.SliceStable(reports, func(i, j int) bool { + if reports[i].WeekLabel != reports[j].WeekLabel { + return reports[i].WeekLabel < reports[j].WeekLabel + } + if len(reports[i].Days) == 0 || len(reports[j].Days) == 0 { + return len(reports[i].Days) < len(reports[j].Days) + } + return reports[i].Days[0].Epoch < reports[j].Days[0].Epoch + }) +} diff --git a/internal/worktime/report_test.go b/internal/worktime/report_test.go new file mode 100644 index 0000000..95ab0c2 --- /dev/null +++ b/internal/worktime/report_test.go @@ -0,0 +1,164 @@ +package worktime + +import ( + "regexp" + "strings" + "testing" + "time" + + "codeberg.org/snonux/timr/internal/config" +) + +func TestBuildReportBalanceAndMarkers(t *testing.T) { + cfg := config.Default() + + entries := []Entry{ + {Action: "login", What: "work", Epoch: localEpoch(2026, 1, 5, 9, 0, 0)}, + {Action: "add", What: "lunch", Epoch: localEpoch(2026, 1, 5, 12, 0, 0), Value: 3600}, + {Action: "logout", What: "work", Epoch: localEpoch(2026, 1, 5, 17, 0, 0)}, + {Action: "add", What: "off", Epoch: localEpoch(2026, 1, 6, 12, 0, 0), Value: 8 * 3600}, + {Action: "login", What: "work", Epoch: localEpoch(2026, 1, 7, 9, 0, 0)}, + {Action: "logout", What: "work", Epoch: localEpoch(2026, 1, 7, 17, 0, 0)}, + } + + weeks, err := BuildReport(entries, cfg) + if err != nil { + t.Fatalf("BuildReport() error = %v", err) + } + + if len(weeks) != 1 { + t.Fatalf("weeks len = %d, want 1", len(weeks)) + } + + week := weeks[0] + if len(week.Days) != 3 { + t.Fatalf("week days len = %d, want 3", len(week.Days)) + } + + mon := week.Days[0] + if mon.Values["work"] != 7*secondsPerHour { + t.Fatalf("monday work = %d, want %d", mon.Values["work"], 7*secondsPerHour) + } + + tue := week.Days[1] + if tue.Marker != "*" { + t.Fatalf("tuesday marker = %q, want *", tue.Marker) + } + if tue.RequiredSeconds != 0 { + t.Fatalf("tuesday required = %d, want 0", tue.RequiredSeconds) + } + + if week.RequiredSeconds != 32*secondsPerHour { + t.Fatalf("week required = %d, want %d", week.RequiredSeconds, 32*secondsPerHour) + } + + if week.Values["work"] != 15*secondsPerHour { + t.Fatalf("week work = %d, want %d", week.Values["work"], 15*secondsPerHour) + } + + if week.WeeklyBalanceSeconds != -17*secondsPerHour { + t.Fatalf("weekly balance = %d, want %d", week.WeeklyBalanceSeconds, -17*secondsPerHour) + } + + if week.CumulativeBalanceSeconds != -17*secondsPerHour { + t.Fatalf("cumulative balance = %d, want %d", week.CumulativeBalanceSeconds, -17*secondsPerHour) + } +} + +func TestBuildReportTracksBufferTotals(t *testing.T) { + cfg := config.Default() + + entries := []Entry{ + {Action: "add", What: "selfdevelopment", Epoch: localEpoch(2026, 1, 5, 11, 0, 0), Value: 2 * 3600}, + {Action: "add", What: "work", Epoch: localEpoch(2026, 1, 5, 12, 0, 0), Value: 3600}, + } + + weeks, err := BuildReport(entries, cfg) + if err != nil { + t.Fatalf("BuildReport() error = %v", err) + } + + if len(weeks) != 1 { + t.Fatalf("weeks len = %d, want 1", len(weeks)) + } + + if weeks[0].BufferSeconds != 2*secondsPerHour { + t.Fatalf("buffer seconds = %d, want %d", weeks[0].BufferSeconds, 2*secondsPerHour) + } +} + +func TestBuildReportRejectsInvalidLoginSequences(t *testing.T) { + cfg := config.Default() + + _, err := BuildReport([]Entry{ + {Action: "logout", What: "work", Epoch: localEpoch(2026, 1, 5, 10, 0, 0)}, + }, cfg) + if err == nil { + t.Fatal("BuildReport() accepted logout without login") + } + + _, err = BuildReport([]Entry{ + {Action: "login", What: "work", Epoch: localEpoch(2026, 1, 5, 9, 0, 0)}, + {Action: "login", What: "work", Epoch: localEpoch(2026, 1, 5, 10, 0, 0)}, + }, cfg) + if err == nil { + t.Fatal("BuildReport() accepted double login") + } +} + +func TestBuildReportRejectsUnknownAction(t *testing.T) { + cfg := config.Default() + + _, err := BuildReport([]Entry{ + {Action: "mystery", What: "work", Epoch: localEpoch(2026, 1, 5, 10, 0, 0)}, + }, cfg) + if err == nil { + t.Fatal("BuildReport() accepted unknown action") + } +} + +func TestBuildReportEmptyInput(t *testing.T) { + weeks, err := BuildReport(nil, config.Default()) + if err != nil { + t.Fatalf("BuildReport() error = %v", err) + } + if len(weeks) != 0 { + t.Fatalf("weeks len = %d, want 0", len(weeks)) + } +} + +func TestFormatReportVerboseAndColor(t *testing.T) { + entries := []Entry{ + {Action: "add", What: "work", Epoch: localEpoch(2026, 1, 5, 10, 0, 0), Value: 3600}, + } + weeks, err := BuildReport(entries, config.Default()) + if err != nil { + t.Fatalf("BuildReport() error = %v", err) + } + + colored := FormatReport(weeks, true, true) + if !strings.Contains(colored, "\x1b[") { + t.Fatalf("colored output does not contain ANSI color sequences: %q", colored) + } + coloredPlain := stripANSI(colored) + if !strings.Contains(coloredPlain, "work:") { + t.Fatalf("colored output missing work field: %q", colored) + } + if !strings.Contains(coloredPlain, "epoch:") { + t.Fatalf("colored output missing verbose epoch: %q", colored) + } + + plain := FormatReport(weeks, false, false) + if strings.Contains(plain, "\x1b[") { + t.Fatalf("plain output contains ANSI color sequences: %q", plain) + } +} + +func localEpoch(year int, month time.Month, day int, hour int, minute int, second int) int64 { + return time.Date(year, month, day, hour, minute, second, 0, time.Local).Unix() +} + +func stripANSI(value string) string { + ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*m`) + return ansiPattern.ReplaceAllString(value, "") +} |
