summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 22:43:03 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 22:43:03 +0200
commiteb14c8808e353ee9b509fbce00c52caa640761b3 (patch)
treed4d434367269ac2bfd75814946ac3a9ddf0f28bf
parentac0a71de2a4a8894de849d10bbc4de962a5c0c2b (diff)
Task 354: add worktime report engine
-rw-r--r--internal/worktime/report.go402
-rw-r--r--internal/worktime/report_test.go164
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(&currentWeek, 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(&currentWeek, 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, "")
+}