tracker/view.go
package main
import (
"fmt"
"html/template"
"net/url"
"sort"
"strings"
"time"
)
// Asia/Bangkok without depending on the host's tzdata.
var ict = time.FixedZone("ICT", 7*3600)
type Counts struct {
Total, Done, InProgress, Review, Reviewed, Todo int
}
func (c *Counts) add(status string) {
c.Total++
switch status {
case StatusDone:
c.Done++
case StatusReviewed:
c.Reviewed++
case StatusInProgress:
c.InProgress++
case StatusReview:
c.Review++
default:
c.Todo++
}
}
// Complete counts finished work: Done (agent finished, tests pass) and
// Reviewed (approved by a reviewer).
func (c Counts) Complete() int { return c.Done + c.Reviewed }
func (c Counts) Percent() int {
if c.Total == 0 {
return 0
}
return c.Complete() * 100 / c.Total
}
type EpicView struct {
Epic
Code string // e.g. "E0.3": milestone number and position in the milestone
Counts Counts
}
type MilestoneView struct {
Milestone
Counts Counts
Epics []EpicView
}
// Where locates a task: which milestone and epic it belongs to.
type Where struct {
MilestoneID string
MilestoneNo int
MilestoneTitle string
EpicID string
EpicCode string
EpicTitle string
}
// TaskView is a task with its location and its steps split into done and left.
type TaskView struct {
Task
Where
Done, Left []Step
}
func (c TaskView) Percent() int {
n := len(c.Done) + len(c.Left)
if n == 0 {
return 0
}
return len(c.Done) * 100 / n
}
type ReviewItem struct {
Task
Where
}
// Crumb is one breadcrumb link; the last one has no Href.
type Crumb struct {
Label, Href string
}
type Page struct {
Mockups *MockupView
UseCases *UCView
Active string
Title string
Saved string // task id whose review was just saved (flash message)
Form *ReviewForm // review form values to show again after a validation error
Timeline *Timeline // set on the timeline page
// Set on the milestone, epic and task pages.
Milestone *MilestoneView
Epic *EpicView
Task *TaskView
Crumbs []Crumb
LoadErr error
LoadedAt time.Time
Now time.Time
Counts Counts
Milestones []MilestoneView
Current []TaskView
Reviews []ReviewItem // reviews still waiting (state todo), oldest first
ReviewTabs map[string][]ReviewItem // every review, by state
ReviewState string // selected tab on the review page
Checkpoints []Checkpoint
Tests []TestRun
TestRuns []TestRunView // Tests filtered by TestView, for the test page
TestView string // "", "ba" or "tech"
TestCounts map[string]int
Problems []Problem
OpenProblem int
Epics map[string]Where // epic id -> location, for linking problems to epics
tasks map[string]TaskView
}
func (p Page) LatestTest() *TestRun {
if len(p.Tests) == 0 {
return nil
}
return &p.Tests[0]
}
func (p Page) LastCheckpoint() *Checkpoint {
if len(p.Checkpoints) == 0 {
return nil
}
return &p.Checkpoints[0]
}
func buildPage(store *Store, active, title string) Page {
snap, loadErr, loadedAt := store.Get()
p := Page{Active: active, Title: title, LoadErr: loadErr, LoadedAt: loadedAt, Now: time.Now(),
Tests: snap.Tests, Problems: snap.Problems}
p.Epics = map[string]Where{}
p.tasks = map[string]TaskView{}
p.ReviewTabs = map[string][]ReviewItem{}
for _, m := range snap.Milestones {
mv := MilestoneView{Milestone: m}
for j, e := range m.Epics {
ev := EpicView{Epic: e, Code: fmt.Sprintf("E%d.%d", m.No, j+1)}
where := Where{MilestoneID: m.ID, MilestoneNo: m.No, MilestoneTitle: m.Title, EpicID: e.ID, EpicCode: ev.Code, EpicTitle: e.Title}
p.Epics[e.ID] = where
for _, t := range e.Tasks {
ev.Counts.add(t.Status)
mv.Counts.add(t.Status)
p.Counts.add(t.Status)
tv := TaskView{Task: t, Where: where}
for _, s := range t.Steps {
if s.Done {
tv.Done = append(tv.Done, s)
} else {
tv.Left = append(tv.Left, s)
}
}
p.tasks[t.ID] = tv
if t.Status == StatusInProgress {
p.Current = append(p.Current, tv)
}
if t.Review != nil {
st := t.Review.StateOrTodo()
p.ReviewTabs[st] = append(p.ReviewTabs[st], ReviewItem{Task: t, Where: where})
}
}
mv.Epics = append(mv.Epics, ev)
}
p.Milestones = append(p.Milestones, mv)
}
for _, pr := range snap.Problems {
if pr.Status != "solved" {
p.OpenProblem++
}
}
// Oldest review request first; requests without a date go last.
for st, items := range p.ReviewTabs {
sort.SliceStable(items, func(i, j int) bool {
a, b := reviewDate(items[i]), reviewDate(items[j])
if a == "" || b == "" {
return b == "" && a != ""
}
return a < b
})
p.ReviewTabs[st] = items
}
p.Reviews = p.ReviewTabs[ReviewTodo]
p.Checkpoints = snap.Checkpoints
return p
}
func reviewDate(r ReviewItem) string {
if r.Review == nil {
return ""
}
return r.Review.RequestedAt
}
type NavItem struct {
Href, Label, Badge string
On, Alert bool
}
func navItem(p Page, href, key, label, badge string) NavItem {
n := NavItem{Href: href, Label: label, Badge: badge, On: p.Active == key || (key == "milestones" && p.Milestone != nil)}
switch key {
case "tests":
n.Alert = p.LatestTest() != nil && p.LatestTest().Failing()
case "problems":
n.Alert = p.OpenProblem > 0
}
return n
}
func testBadge(p Page) string {
t := p.LatestTest()
if t == nil {
return ""
}
if t.Failing() {
return "ไม่ผ่าน"
}
return "ผ่าน"
}
func problemBadge(p Page) string {
if p.OpenProblem == 0 {
return fmt.Sprintf("%d", len(p.Problems))
}
return fmt.Sprintf("%d เปิดอยู่", p.OpenProblem)
}
var thaiMonths = []string{"ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."}
// thaiDate formats with the Buddhist-era year, e.g. "30 ต.ค. 2569".
func thaiDate(t time.Time) string {
if t.IsZero() {
return ""
}
t = t.In(ict)
return fmt.Sprintf("%d %s %d", t.Day(), thaiMonths[t.Month()-1], t.Year()+543)
}
func thaiDateTime(t time.Time) string {
if t.IsZero() {
return ""
}
return thaiDate(t) + " " + t.In(ict).Format("15:04")
}
// parseDay accepts "2006-01-02" or RFC 3339.
func parseDay(s string) time.Time {
if t, err := time.ParseInLocation("2006-01-02", s, ict); err == nil {
return t
}
t, _ := time.Parse(time.RFC3339, s)
return t
}
func ago(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "เมื่อสักครู่"
case d < time.Hour:
return fmt.Sprintf("%d นาทีที่แล้ว", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%d ชั่วโมงที่แล้ว", int(d.Hours()))
case d < 30*24*time.Hour:
return fmt.Sprintf("%d วันที่แล้ว", int(d.Hours()/24))
}
return thaiDate(t)
}
var statusLabels = map[string]string{
StatusDone: "Done",
StatusReviewed: "Reviewed",
StatusInProgress: "In Progress",
StatusReview: "Wait for review",
StatusTodo: "Todo",
}
func moduleLabel(names map[string]string, code string) string {
if name := names[code]; name != "" {
return name
}
return "โมดูลที่ยังไม่ระบุชื่อ"
}
func templateFuncs() template.FuncMap {
return template.FuncMap{
"moduleLabel": moduleLabel,
"asset": assetURL,
"thaiDate": func(s string) string { return thaiDate(parseDay(s)) },
"thaiDateTime": thaiDateTime,
"ago": ago,
"statusLabel": func(s string) string { return statusLabels[s] },
"pct": func(n, total int) string {
if total == 0 {
return "0"
}
return fmt.Sprintf("%.3f", float64(n)*100/float64(total))
},
"join": strings.Join,
"navItem": navItem,
"milestoneHref": milestoneHref,
"epicHref": epicHref,
"taskHref": taskHref,
"audience": func(a string) string { return audienceLabels[a] },
"md": markdown,
"refHref": refHref,
"barEnd": func(b *TLBar) float64 { return b.Left + b.Width },
"reviewForm": formFor,
"reviewState": func(s string) string { return reviewStateLabels[s] },
"checklistDone": func(items []ChecklistItem) int {
n := 0
for _, it := range items {
if it.Done {
n++
}
}
return n
},
"testBadge": testBadge,
"problemBadge": problemBadge,
"add": func(a, b int) int { return a + b },
"stepsDone": func(steps []Step) int {
n := 0
for _, s := range steps {
if s.Done {
n++
}
}
return n
},
}
}
// Selection for the milestone, epic and task pages. Each returns false when the
// id does not exist, so the handler can answer 404.
func (p *Page) SelectMilestone(id string) bool {
for i := range p.Milestones {
if p.Milestones[i].ID == id {
m := &p.Milestones[i]
p.Milestone = m
p.Title = fmt.Sprintf("Milestone %d %s", m.No, m.Title)
p.Crumbs = []Crumb{{"Milestone & Tasks", "/milestones"}, {fmt.Sprintf("Milestone %d", m.No), ""}}
return true
}
}
return false
}
func (p *Page) SelectEpic(milestoneID, epicID string) bool {
if !p.SelectMilestone(milestoneID) {
return false
}
m := p.Milestone
for i := range m.Epics {
if m.Epics[i].ID == epicID {
e := &m.Epics[i]
p.Epic = e
p.Title = e.Code + " " + e.Title
p.Crumbs = []Crumb{{"Milestone & Tasks", "/milestones"}, {fmt.Sprintf("Milestone %d", m.No), milestoneHref(m.ID)}, {e.Code + " " + e.Title, ""}}
return true
}
}
return false
}
func (p *Page) SelectTask(id string) bool {
t, ok := p.tasks[id]
if !ok || !p.SelectEpic(t.MilestoneID, t.EpicID) {
return false
}
p.Task = &t
p.Title = t.ID + " " + t.Title
p.Crumbs = []Crumb{{"Milestone & Tasks", "/milestones"}, {fmt.Sprintf("Milestone %d", t.MilestoneNo), milestoneHref(t.MilestoneID)},
{t.EpicCode + " " + t.EpicTitle, epicHref(t.MilestoneID, t.EpicID)}, {t.ID, ""}}
return true
}
func milestoneHref(id string) string { return "/milestones/" + url.PathEscape(id) }
func epicHref(milestoneID, epicID string) string {
return milestoneHref(milestoneID) + "/" + url.PathEscape(epicID)
}
func taskHref(id string) string { return "/tasks/" + url.PathEscape(id) }
// TestRunView is a run with only the groups for the selected audience.
type TestRunView struct {
TestRun
Shown []TestGroup
Pass, Fail, Skip int // written checks in Shown
}
var audienceLabels = map[string]string{"ba": "BA", "tech": "Technical", "both": "BA และ Technical"}
// SetTestView filters test groups: "ba" shows ba and both, "tech" shows tech
// and both, anything else shows everything.
func (p *Page) SetTestView(view string) {
if view != "ba" && view != "tech" {
view = ""
}
p.TestView = view
p.TestCounts = map[string]int{}
p.TestRuns = nil
for _, r := range p.Tests {
rv := TestRunView{TestRun: r}
for _, g := range r.Groups {
for _, aud := range []string{"", "ba", "tech"} {
if visible(g.Audience, aud) {
p.TestCounts[aud] += len(g.Cases)
}
}
if !visible(g.Audience, view) {
continue
}
rv.Shown = append(rv.Shown, g)
for _, c := range g.Cases {
switch c.Status {
case "pass":
rv.Pass++
case "fail":
rv.Fail++
default:
rv.Skip++
}
}
}
// Runs without written groups only appear in the full view.
if len(rv.Shown) > 0 || (view == "" && len(r.Groups) == 0) {
p.TestRuns = append(p.TestRuns, rv)
}
}
}
func visible(audience, view string) bool {
return view == "" || audience == view || audience == "both" || audience == ""
}
var reviewStateLabels = map[string]string{ReviewTodo: "Todo", ReviewRejected: "Rejected", ReviewHold: "Hold", ReviewDone: "Done"}
// ReviewTabOrder is the order of the tabs on the review page.
var ReviewTabOrder = []string{ReviewTodo, ReviewRejected, ReviewHold, ReviewDone}
// SetReviewState selects a review tab; unknown values fall back to todo.
func (p *Page) SetReviewState(state string) {
if !validReviewState[state] || state == "" {
state = ReviewTodo
}
p.ReviewState = state
}
// ReviewTabItems lists the reviews in the selected tab.
func (p Page) ReviewTabItems() []ReviewItem { return p.ReviewTabs[p.ReviewState] }
func (p Page) ReviewTabsInOrder() []string { return ReviewTabOrder }
// ReviewForm holds what the reviewer submitted, to refill the form on error.
type ReviewForm struct {
TaskID, Outcome, Decision, Reviewer, Error string
Checked map[int]bool
}
// FormView is what the review form template needs for one task.
type FormView struct {
TaskID, Return string
Outcome, Decision, Reviewer, Error string
Items []FormItem
}
type FormItem struct {
Index int
Text string
Checked bool
}
var stateOutcome = map[string]string{ReviewDone: "approve", ReviewRejected: "reject", ReviewHold: "hold"}
// formFor prepares the review form of one task: the submitted values after a
// validation error, otherwise the current checklist and outcome.
func formFor(p Page, id string, r *Review, ret string) FormView {
f := FormView{TaskID: id, Return: ret}
if r == nil {
return f
}
sub := p.Form
if sub != nil && sub.TaskID != id {
sub = nil
}
if sub != nil {
f.Outcome, f.Decision, f.Reviewer, f.Error = sub.Outcome, sub.Decision, sub.Reviewer, sub.Error
} else {
f.Outcome = stateOutcome[r.StateOrTodo()]
}
for i, it := range r.Checklist {
checked := it.Done
if sub != nil {
checked = sub.Checked[i]
}
f.Items = append(f.Items, FormItem{Index: i, Text: it.Text, Checked: checked})
}
return f
}