diff options
Diffstat (limited to 'zeitgeist.go')
-rw-r--r-- | zeitgeist.go | 457 |
1 files changed, 457 insertions, 0 deletions
diff --git a/zeitgeist.go b/zeitgeist.go new file mode 100644 index 0000000..7cdc968 --- /dev/null +++ b/zeitgeist.go @@ -0,0 +1,457 @@ +package main + +import ( + "bufio" + "embed" + "encoding/json" + "flag" + "fmt" + "log" + "log/slog" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "text/template" + "time" +) + +var ( + fl_project = flag.String("pr", "project.json", "project-file") + fl_timelogs = flag.String("lt", "", "directory to search for timelogs") + fl_plandir = flag.String("pl", "", "directory to search for plans") + fl_templates = flag.String("tm", "", "directory to search for the templates") + fl_report = flag.String("rp", "report.t", "default report template") +) + +type Project struct { + Name string + Grant string + Start Date + End Date + Beneficiary string + Users map[string]User + Timelogs string + Planning string + Workpackages map[string]Workpackage + Holidays map[Date]string + Timeline Timeline + Planned Timeline +} + +type Timeline map[Date]Entries + +type Entries []Entry + +func (en Entries) Total() Amount { + a := Amount(0.0) + for _, e := range en { + a += e.Amount + } + return a +} + +func (en Entries) WPs() string { + wps := []string{} + for _, e := range en { + wps = append(wps, e.WP) + } + wps = slices.Compact(wps) + return strings.Join(wps, ", ") +} + +type User struct { + Name string + Type string +} + +type Workpackage struct { + Title string + Short string + StartMonth uint + EndMonth uint + TotalTime Amount + Tasks map[string]Task +} + +type Date time.Time + +func (d Date) IsWorkday() bool { + wd := (time.Time(d)).Weekday() + return wd > time.Sunday && wd < time.Saturday +} + +func (d *Date) UnmarshalText(b []byte) error { + t, e := time.Parse(time.DateOnly, string(b)) + if e != nil { + return e + } + *d = Date(t) + return e +} + +func (d Date) String() string { + return (time.Time(d)).Format(time.DateOnly) +} + +func (d Date) MarshalJSON() ([]byte, error) { + return []byte(d.String()), nil +} + +func (d Date) Year() int { + return (time.Time(d)).Year() +} + +type Amount float64 + +const ( + Days = 215 + DaysInMonth = float64(215) / 12 +) + +func (u Amount) String() string { + return fmt.Sprintf("%5.2f", u) +} + +func (b Amount) AsHour() string { + return b.String() + "h" +} + +func (b Amount) AsDay() string { + return (b / 8).String() + "d" +} + +func (b Amount) AsMonth() string { + return (b / 8 / Amount(DaysInMonth)).String() + "m" +} + +func (u *Amount) UnmarshalText(b []byte) error { + return u.parseString(string(b)) +} + +func (u *Amount) parseString(s string) error { + f, e := strconv.ParseFloat(s[:len(s)-1], 64) + if e != nil { + return fmt.Errorf("not a float: %v", s) + } + switch s[len(s)-1] { + case 'm', 'M': + f *= DaysInMonth + fallthrough + case 'd', 'D': + f *= 8 + fallthrough + case 'h', 'H': + *u = Amount(f) + default: + return fmt.Errorf("unknown unit in: %v", s) + } + return nil +} + +type Task struct { + ID string + Title string + Budget Amount +} + +type Entry struct { + User string + WP string + Task string + Amount Amount +} + +type Deliverable struct { + ID string + Title string + Description string +} + +func main() { + flag.Parse() + + p, e := loadProject(*fl_project) + if e != nil { + log.Fatalf("error loading project: %v", e) + } + if p.Timeline == nil { + p.Timeline = make(Timeline) + } + + e = p.readPlans(*fl_plandir) + if e != nil { + log.Fatalf("error loading plans: %v", e) + } + + e = p.readTimelogs(*fl_timelogs) + if e != nil { + log.Fatalf("error loading timelog: %v", e) + } + + p.printReport(*fl_report) +} + +var min2w = regexp.MustCompile("[\t ]+") + +func loadProject(f string) (p *Project, e error) { + fd, e := os.Open(f) + if e != nil { + return nil, e + } + defer fd.Close() + + p = &Project{} + d := json.NewDecoder(fd) + d.DisallowUnknownFields() + e = d.Decode(p) + + return p, e +} + +func (p *Project) readPlans(dir string) error { + if len(dir) == 0 { + dir = p.Planning + } + glob := dir + "/*.plan" + matches, e := filepath.Glob(glob) + if e != nil { + return fmt.Errorf("filepath.Glob(%q): %w", glob, e) + } + + if len(matches) == 0 { + slog.Warn("no plans found:", "glob", glob) + } + + for _, name := range matches { + f, e := os.Open(name) + if e != nil { + return fmt.Errorf("os.Open(%q) failed: %w", name, e) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + nr := 1 + last := time.Time{} + for scanner.Scan() { + // # Ignore line + // # Tab-separated fields + // 2024-02 wp2:short 1m [comment] + // 2024-02-11 wp2:short 1d [comment] + // 2024-02-11..2024-02-23 wp2:short *8h [comment] + // 2024-02-11..2024-02-23 wp2:short 3d [comment] + var line = scanner.Text() + if strings.HasPrefix(line, "#") { + continue + } + + parts := min2w.Split(scanner.Text(), 3) + if len(parts) < 3 { + return fmt.Errorf("file %q, line %d: not enough tab-separated fields", name, nr) + } + + d, e := time.Parse(time.DateOnly, parts[0]) + if e != nil { + return fmt.Errorf("file %q, line %d: couldn't parse date: %w", name, nr, e) + } + if last.After(d) { + return fmt.Errorf("file %q, line %d: dates not monotonic, %s older", name, nr, Date(d)) + } + last = d + if !Date(d).IsWorkday() { + return fmt.Errorf("file %q, line %d: date is a weekend: %v", name, nr, d) + } + if h, found := p.Holidays[Date(d)]; found { + return fmt.Errorf("file %q, line %d: date %v is a holiday: %s", name, nr, Date(d), h) + } + + wps := strings.Split(parts[1], ":") + if len(wps) < 2 { + return fmt.Errorf("file %q, line %d: no such workpackage:task %q", name, nr, parts[1]) + } + if wp, ok := p.Workpackages[wps[0]]; !ok { + return fmt.Errorf("file %q, line %d: no such workpackage %q", name, nr, wps[0]) + } else if _, ok := wp.Tasks[wps[1]]; !ok { + return fmt.Errorf("file %q, line %d: no such task %q in %q", name, nr, wps[1], wps[0]) + } + + var amount Amount + e = (&amount).parseString(parts[2]) + if e != nil { + return fmt.Errorf("file %q, line %d: not acceptible budget: %q", name, nr, parts[2]) + } + + entry := Entry{ + User: parts[1], + WP: wps[0], + Task: wps[1], + Amount: amount} + + entries := p.Timeline[Date(d)] + entries = append(entries, entry) + p.Timeline[Date(d)] = entries + + nr += 1 + } + + } + return nil +} + +func (p *Project) readTimelogs(dir string) error { + if len(dir) == 0 { + dir = p.Timelogs + } + glob := dir + "/*.timelog" + matches, e := filepath.Glob(glob) + if e != nil { + return fmt.Errorf("filepath.Glob(%q): %w", glob, e) + } + + if len(matches) == 0 { + slog.Warn("no timelogs found:", "glob", glob) + } + + for _, name := range matches { + var year uint16 + var user string + { + s := strings.Replace(filepath.Base(name), ".", " ", 2) + n, e := fmt.Sscanf(s, "%d %s timelog", &year, &user) + if e != nil { + return fmt.Errorf("fmt.Sscanf(%q): %w", s, e) + } else if n != 2 { + return fmt.Errorf("file %q not in the format <year>.<user>.timelog") + } + } + + if _, ok := p.Users[user]; !ok { + return fmt.Errorf("Unknown user: %v", user) + } + if int(year) < p.Start.Year() || p.End.Year() < int(year) { + return fmt.Errorf("year out of range: %d (not in %d-%d)", year, p.Start.Year(), p.End.Year()) + } + + f, e := os.Open(name) + if e != nil { + return fmt.Errorf("os.Open(%q) failed: %w", name, e) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + nr := 1 + last := time.Time{} + for scanner.Scan() { + // # Ignore line + // # Tab-separated fields + // 2024-12-11 wp2:short 8h [comment] + var line = scanner.Text() + if strings.HasPrefix(line, "#") { + continue + } + + parts := min2w.Split(scanner.Text(), 3) + if len(parts) < 3 { + return fmt.Errorf("file %q, line %d: not enough tab-separated fields", name, nr) + } + + d, e := time.Parse(time.DateOnly, parts[0]) + if e != nil { + return fmt.Errorf("file %q, line %d: couldn't parse date: %w", name, nr, e) + } + if d.Year() != int(year) { + return fmt.Errorf("file %q, line %d: wrong year: %d", name, nr, d.Year()) + } + if last.After(d) { + return fmt.Errorf("file %q, line %d: dates not monotonic, %s older", name, nr, Date(d)) + } + last = d + if !Date(d).IsWorkday() { + return fmt.Errorf("file %q, line %d: date is a weekend: %v", name, nr, d) + } + if h, found := p.Holidays[Date(d)]; found { + return fmt.Errorf("file %q, line %d: date %v is a holiday: %s", name, nr, Date(d), h) + } + + wps := strings.Split(parts[1], ":") + if len(wps) < 2 { + return fmt.Errorf("file %q, line %d: no such workpackage:task %q", name, nr, parts[1]) + } + if wp, ok := p.Workpackages[wps[0]]; !ok { + return fmt.Errorf("file %q, line %d: no such workpackage %q", name, nr, wps[0]) + } else if _, ok := wp.Tasks[wps[1]]; !ok { + return fmt.Errorf("file %q, line %d: no such task %q in %q", name, nr, wps[1], wps[0]) + } + + var amount Amount + e = (&amount).parseString(parts[2]) + if e != nil { + return fmt.Errorf("file %q, line %d: not acceptible budget: %q", name, nr, parts[2]) + } + + entry := Entry{ + User: parts[1], + WP: wps[0], + Task: wps[1], + Amount: amount} + + entries := p.Timeline[Date(d)] + entries = append(entries, entry) + p.Timeline[Date(d)] = entries + + nr += 1 + } + } + return nil +} + +//go:embed templates/*.t +var embedFS embed.FS + +func (p *Project) printReport(name string) { + var tmp *template.Template + var e error + + if *fl_templates != "" { + tmp, e = template.ParseGlob(*fl_templates + "/*.t") + } else { + tmp, e = template.ParseFS(embedFS, "templates/*.t") + } + if e != nil { + log.Fatalf("Couldn't parse templates: %v\n", e) + } + + if nil == tmp.Lookup(name) { + log.Fatalf("Couldn't find template %q\n", name) + } + + e = tmp.ExecuteTemplate(os.Stdout, name, p) + if e != nil { + log.Fatalf("Couldn't execute template %q: %v\n", name, e) + } +} + +type FullDate struct { + Date + IsWorkday bool + IsHoliday bool +} + +func (fd *FullDate) IsWeekend() bool { + return !fd.IsWorkday +} + +func (p *Project) IsHoliday(d Date) bool { + _, ok := p.Holidays[d] + return ok +} + +func (p *Project) Days() []FullDate { + dates := []FullDate{} + for d := time.Time(p.Start); d.Before(time.Time(p.End)); d = d.AddDate(0, 0, 1) { + dates = append(dates, FullDate{Date(d), Date(d).IsWorkday(), p.IsHoliday(Date(d))}) + } + return dates +} |