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

tracker/timeline.go

package main

import (
	"fmt"
	"strings"
	"time"
)

// Timeline (Gantt) view: Milestone > Epic > Task rows on the left, bars on a
// time axis on the right. Everything is positioned on the server as a
// percentage of the chart width, so the page renders without JavaScript and
// the chart stretches to fill wide screens; Width (from the scale's PX) is only
// the minimum width before the chart scrolls sideways. The script collapses
// rows, resizes the label column and scrolls to "now".

type tlScale struct {
	Name  string
	Label string
	Unit  time.Duration
	PX    float64       // width of one unit
	Pad   time.Duration // empty space before the first and after the last bar
	Max   int           // most units to draw (keeps the page light)
	Min   int           // fewest units to draw, so short projects still fill the view
}

var tlScales = []tlScale{
	{"hour", "ชั่วโมง", time.Hour, 34, 6 * time.Hour, 24 * 21, 48},
	{"day", "วัน", 24 * time.Hour, 44, 2 * 24 * time.Hour, 400, 21},
	{"week", "สัปดาห์", 7 * 24 * time.Hour, 84, 7 * 24 * time.Hour, 160, 12},
}

func scaleByName(name string) tlScale {
	for _, s := range tlScales {
		if s.Name == name {
			return s
		}
	}
	return tlScales[1] // day
}

// span is a time interval; zero values mean "none".
type span struct{ From, To time.Time }

func (s span) ok() bool { return !s.From.IsZero() && s.To.After(s.From) }

func (s span) union(o span) span {
	switch {
	case !s.ok():
		return o
	case !o.ok():
		return s
	}
	if o.From.Before(s.From) {
		s.From = o.From
	}
	if o.To.After(s.To) {
		s.To = o.To
	}
	return s
}

// parseWhen reads "2006-01-02" or RFC 3339. A date alone covers the whole day.
func parseWhen(v string) (t time.Time, dateOnly bool) {
	v = strings.TrimSpace(v)
	if v == "" {
		return time.Time{}, false
	}
	if t, err := time.ParseInLocation("2006-01-02", v, ict); err == nil {
		return t, true
	}
	t, _ = time.Parse(time.RFC3339, v)
	return t, false
}

// endOf gives the end instant for a finish/due value: the end of the day for
// a date, the moment itself for a timestamp.
func endOf(v string) time.Time {
	t, dateOnly := parseWhen(v)
	if dateOnly {
		return t.Add(24 * time.Hour)
	}
	return t
}

// actualSpan is when the work really happened: started -> finished, or
// started -> now while it is still open.
func actualSpan(t Task, now time.Time) span {
	start, _ := parseWhen(t.Started)
	if start.IsZero() {
		return span{}
	}
	end := endOf(t.Finished)
	if end.IsZero() {
		switch t.Status {
		case StatusInProgress, StatusReview:
			end = now
		default: // done without a finish date: show the start day
			end = endOf(t.Started)
		}
	}
	if !end.After(start) {
		end = start.Add(time.Hour)
	}
	return span{start, end}
}

// plannedSpan is the plan: start (or started) -> due.
func plannedSpan(t Task) span {
	if t.Due == "" {
		return span{}
	}
	start, _ := parseWhen(t.Start)
	if start.IsZero() {
		start, _ = parseWhen(t.Started)
	}
	due := endOf(t.Due)
	if start.IsZero() {
		start = due.Add(-24 * time.Hour)
	}
	return span{start, due}
}

type TLRow struct {
	Kind     string // milestone | epic | task
	ID       string
	Parent   string // id of the row this one folds under
	Code     string // "0", "E0.3", "T-201"
	Label    string
	Href     string
	Status   string
	Level    int
	Percent  int // milestone and epic: done + reviewed share
	Bar      *TLBar
	Plan     *TLBar
	Marker   *TLBar // milestone target
	Title    string // tooltip
	Duration string
}

type TLBar struct{ Left, Width float64 }

type TLTick struct {
	Left, Width float64
	Label       string
	Weekend     bool
}

type Timeline struct {
	Scale       tlScale
	Scales      []tlScale
	From, To    time.Time
	Width       float64 // minimum chart width in pixels
	UnitPct     float64 // width of one unit, % of the chart
	Major       []TLTick
	Minor       []TLTick
	Now         float64 // x of "now" in %; -1 when outside the range
	Rows        []TLRow
	Unscheduled int  // tasks with no dates at all
	Clipped     bool // range was cut to the scale's limit
}

// buildTimeline lays out every milestone, epic and task on the chosen scale.
func buildTimeline(ms []MilestoneView, scaleName string, now time.Time) Timeline {
	sc := scaleByName(scaleName)
	tl := Timeline{Scale: sc, Scales: tlScales}

	var all span
	taskSpans := map[string][2]span{}
	epicSpans := map[string]span{}
	msSpans := map[string]span{}
	for _, m := range ms {
		var mspan span
		for _, e := range m.Epics {
			var es span
			for _, t := range e.Tasks {
				a, p := actualSpan(t, now), plannedSpan(t)
				taskSpans[t.ID] = [2]span{a, p}
				es = es.union(a).union(p)
				if !a.ok() && !p.ok() {
					tl.Unscheduled++
				}
			}
			epicSpans[e.ID] = es
			mspan = mspan.union(es)
		}
		if tgt := endOf(m.Target); !tgt.IsZero() {
			mspan = mspan.union(span{tgt.Add(-24 * time.Hour), tgt})
		}
		msSpans[m.ID] = mspan
		all = all.union(mspan)
	}
	all = all.union(span{now.Add(-sc.Unit), now.Add(sc.Unit)})

	// Range: padded, snapped to the unit, capped to the scale's limit around now.
	from := floorTo(all.From.Add(-sc.Pad), sc)
	to := floorTo(all.To.Add(sc.Pad), sc).Add(sc.Unit)
	if units := int(to.Sub(from) / sc.Unit); units < sc.Min {
		extra := time.Duration(sc.Min-units) * sc.Unit
		from = floorTo(from.Add(-extra/2), sc)
		to = from.Add(time.Duration(sc.Min) * sc.Unit)
		if all.To.After(to) {
			to = floorTo(all.To, sc).Add(sc.Unit)
		}
	}
	if units := int(to.Sub(from) / sc.Unit); units > sc.Max {
		tl.Clipped = true
		half := time.Duration(sc.Max/2) * sc.Unit
		from = floorTo(now.Add(-half), sc)
		to = from.Add(time.Duration(sc.Max) * sc.Unit)
	}
	tl.From, tl.To = from, to
	units := float64(to.Sub(from) / sc.Unit)
	tl.Width = units * sc.PX
	tl.UnitPct = 100 / units

	x := func(t time.Time) float64 { return float64(t.Sub(from)) / float64(to.Sub(from)) * 100 }
	bar := func(s span) *TLBar {
		if !s.ok() || !s.To.After(from) || !s.From.Before(to) {
			return nil
		}
		l, r := x(s.From), x(s.To)
		if l < 0 {
			l = 0
		}
		if r > 100 {
			r = 100
		}
		return &TLBar{Left: l, Width: r - l}
	}
	tl.Now = -1
	if !now.Before(from) && now.Before(to) {
		tl.Now = x(now)
	}
	tl.Major, tl.Minor = ticks(sc, from, to, x, tl.UnitPct)

	for _, m := range ms {
		row := TLRow{Kind: "milestone", ID: m.ID, Code: fmt.Sprint(m.No), Label: m.Title, Href: milestoneHref(m.ID),
			Percent: m.Counts.Percent(), Bar: bar(msSpans[m.ID]), Title: spanText(msSpans[m.ID])}
		if tgt := endOf(m.Target); !tgt.IsZero() {
			row.Marker = bar(span{tgt.Add(-time.Minute), tgt})
		}
		row.Duration = durationText(msSpans[m.ID])
		tl.Rows = append(tl.Rows, row)
		for _, e := range m.Epics {
			es := epicSpans[e.ID]
			tl.Rows = append(tl.Rows, TLRow{Kind: "epic", ID: e.ID, Parent: m.ID, Code: e.Code, Label: e.Title,
				Href: epicHref(m.ID, e.ID), Level: 1, Percent: e.Counts.Percent(), Bar: bar(es), Title: spanText(es), Duration: durationText(es)})
			for _, t := range e.Tasks {
				sp := taskSpans[t.ID]
				tl.Rows = append(tl.Rows, TLRow{Kind: "task", ID: t.ID, Parent: e.ID, Code: t.ID, Label: t.Title,
					Href: taskHref(t.ID), Status: t.Status, Level: 2, Bar: bar(sp[0]), Plan: bar(sp[1]),
					Title: taskTitle(t, sp[0], sp[1]), Duration: durationText(sp[0])})
			}
		}
	}
	return tl
}

// floorTo snaps to the start of the unit: the hour, the day, or Monday.
func floorTo(t time.Time, sc tlScale) time.Time {
	t = t.In(ict)
	switch sc.Name {
	case "hour":
		return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, ict)
	case "week":
		d := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, ict)
		wd := (int(d.Weekday()) + 6) % 7 // Monday = 0
		return d.AddDate(0, 0, -wd)
	default:
		return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, ict)
	}
}

// ticks gives the header: a major row (day for hours, month for days and
// weeks) and a minor row (one cell per unit).
func ticks(sc tlScale, from, to time.Time, x func(time.Time) float64, unit float64) (major, minor []TLTick) {
	for t := from; t.Before(to); t = t.Add(sc.Unit) {
		tt := t.In(ict)
		label := ""
		switch sc.Name {
		case "hour":
			label = tt.Format("15")
		case "day":
			label = fmt.Sprint(tt.Day())
		case "week":
			label = fmt.Sprintf("%d %s", tt.Day(), thaiMonths[tt.Month()-1])
		}
		wd := tt.Weekday()
		minor = append(minor, TLTick{Left: x(t), Width: unit, Label: label,
			Weekend: sc.Name != "week" && (wd == time.Saturday || wd == time.Sunday)})
	}
	// Major groups.
	key := func(t time.Time) string {
		t = t.In(ict)
		if sc.Name == "hour" {
			return t.Format("2006-01-02")
		}
		return t.Format("2006-01")
	}
	label := func(t time.Time) string {
		t = t.In(ict)
		if sc.Name == "hour" {
			return thaiDate(t)
		}
		return fmt.Sprintf("%s %d", thaiMonths[t.Month()-1], t.Year()+543)
	}
	start := from
	for t := from; ; t = t.Add(sc.Unit) {
		if !t.Before(to) || key(t) != key(start) {
			major = append(major, TLTick{Left: x(start), Width: x(t) - x(start), Label: label(start)})
			if !t.Before(to) {
				break
			}
			start = t
		}
	}
	return major, minor
}

func durationText(s span) string {
	if !s.ok() {
		return ""
	}
	d := s.To.Sub(s.From)
	switch {
	case d < time.Hour:
		return fmt.Sprintf("%d นาที", int(d.Minutes())+1)
	case d < 24*time.Hour:
		return fmt.Sprintf("%.0f ชม.", d.Hours())
	default:
		days := d.Hours() / 24
		if days == float64(int(days)) {
			return fmt.Sprintf("%d วัน", int(days))
		}
		return fmt.Sprintf("%.1f วัน", days)
	}
}

func spanText(s span) string {
	if !s.ok() {
		return "ยังไม่มีช่วงเวลา"
	}
	return fmt.Sprintf("%s ถึง %s (%s)", thaiDateTime(s.From), thaiDateTime(s.To), durationText(s))
}

func taskTitle(t Task, actual, plan span) string {
	parts := []string{t.ID + " " + t.Title, statusLabels[t.Status]}
	if actual.ok() {
		parts = append(parts, "ทำจริง: "+spanText(actual))
	}
	if plan.ok() {
		parts = append(parts, "แผน: "+spanText(plan))
	}
	if !actual.ok() && !plan.ok() {
		parts = append(parts, "ยังไม่ได้กำหนดวัน")
	}
	return strings.Join(parts, " | ")
}