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, " | ")
}