ความคืบหน้างาน ไฟล์ต้นฉบับ

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
}