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 }