summaryrefslogtreecommitdiff
path: root/zeitgeist.go
diff options
context:
space:
mode:
Diffstat (limited to 'zeitgeist.go')
-rw-r--r--zeitgeist.go457
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
+}