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