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() } func (a Date) After(b Date) bool { return (time.Time(a)).After(time.Time(b)) } type Amount float64 const ( Days Amount = Amount(215) DaysInMonth = Amount(Days) / 12 HoursInDay = Amount(8) ) func (u Amount) String() string { d := u / HoursInDay m := d / DaysInMonth h := u - Amount(int(d))*HoursInDay r := "" if int(m) > 0 { r = fmt.Sprintf("%dm", int(m)) } if int(d) > 0 { r += fmt.Sprintf("%dd", int(d)) } if h > 0 { r += fmt.Sprintf("%dh", int(h)) } return r } func (b Amount) AsHour() string { return fmt.Sprintf("%5.2fh", b) } func (b Amount) AsDay() string { return fmt.Sprintf("%5.2fd", b/HoursInDay) } func (b Amount) AsMonth() string { return fmt.Sprintf("%5.2fm", b/HoursInDay/Amount(DaysInMonth)) } 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) } *u = Amount(f) switch s[len(s)-1] { case 'm', 'M': *u *= DaysInMonth fallthrough case 'd', 'D': *u *= HoursInDay fallthrough case 'h', 'H': 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 Comment string } 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.Start.After(p.End) { log.Fatalf("start '%s' after end '%s'", p.Start, p.End) } if p.Timeline == nil { p.Timeline = make(Timeline) } if p.Planned == nil { p.Planned = 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 := filepath.Join(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(), 4) if len(parts) < 3 { return fmt.Errorf("file %q, line %d: not enough tab-separated fields", name, nr) } var isrange bool var r [2]time.Time var e error r[0], e = time.Parse(time.DateOnly, parts[0]) if e == nil { r[1] = r[0].AddDate(0, 0, 1) } else { r[0], e = time.Parse("2006-01", parts[0]) if e == nil { slog.Info("Month found", "date", parts[0]) r[1] = r[0].AddDate(0, 1, 0) isrange = true } else { // try xxxx-yy-zz..xxxx-yy-zz ts := strings.Split(parts[0], "..") if len(ts) != 2 { return fmt.Errorf("file %q, line %d: couldn't parse date: %w", name, nr, e) } for i := range r { r[i], e = time.Parse(time.DateOnly, ts[i]) if e != nil { return fmt.Errorf("file %q, line %d: invalid range: %s", name, nr, parts[0]) } } if r[0].Equal(r[1]) || r[0].After(r[1]) { return fmt.Errorf("file %q, line %d: invalid range: %s", name, nr, parts[0]) } slog.Info("Found range", "start", r[0], "end", r[1]) isrange = true } } if !isrange { if last.After(r[0]) { return fmt.Errorf("file %q, line %d: dates not monotonic, %s older", name, nr, Date(r[0])) } if !Date(r[0]).IsWorkday() { return fmt.Errorf("file %q, line %d: date is a weekend: %v", name, nr, r[0]) } if h, found := p.Holidays[Date(r[0])]; found { return fmt.Errorf("file %q, line %d: date %v is a holiday: %s", name, nr, Date(r[0]), h) } } else { // TODO: check range soundness } 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 var days Amount e = (&amount).parseString(parts[2]) // TODO: parse "*8h" etc. if e != nil { return fmt.Errorf("file %q, line %d: not acceptible budget: %q", name, nr, parts[2]) } for d := r[0]; !d.Equal(r[1]); d = d.AddDate(0, 0, 1) { last = d if !Date(d).IsWorkday() || p.IsHoliday(Date(d)) { continue } days += HoursInDay a := amount if amount-HoursInDay > 0 { a = HoursInDay amount -= a } else { a = amount amount = 0 } if a > 0 { entry := Entry{ User: parts[1], WP: wps[0], Task: wps[1], Amount: a, Comment: parts[3]} entries := p.Timeline[Date(d)] entries = append(entries, entry) p.Planned[Date(d)] = entries } } if amount != 0 { return fmt.Errorf("file %q, line %d: too many hours allocated (%s), maximum %s possible", name, nr, parts[2], days) } nr += 1 } } return nil } func (p *Project) readTimelogs(dir string) error { if len(dir) == 0 { dir = p.Timelogs } glob := filepath.Join(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 ..timelog", name) } } 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 Planned Entries Worked Entries 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 (f *FullDate) Difference() Amount { return f.Planned.Total() - f.Worked.Total() } 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) { d := Date(d) dates = append(dates, FullDate{ Date: d, Planned: p.Planned[d], Worked: p.Timeline[d], IsWorkday: d.IsWorkday(), IsHoliday: p.IsHoliday(d), }) } return dates }