period.go (4338B)
1 package utils 2 3 import ( 4 "fmt" 5 "strings" 6 "time" 7 ) 8 9 // now is the clock "present" is measured against; tests pin it. 10 var now = time.Now 11 12 // spanEnd is the last month of a role: its end date, or this month while 13 // it is current. 14 func spanEnd(endDate *time.Time, current bool) time.Time { 15 if !current && endDate != nil { 16 return *endDate 17 } 18 return now() 19 } 20 21 // monthIndex counts months from year 0, so differences are month counts. 22 func monthIndex(t time.Time) int { 23 return t.Year()*12 + int(t.Month()) - 1 24 } 25 26 // MonthsDuration counts the calendar months a role covers, both ends 27 // included (feb 2026 -> feb 2026 is 1 month), the way resumes count. 28 func MonthsDuration(startDate time.Time, endDate *time.Time, current bool) int { 29 months := monthIndex(spanEnd(endDate, current)) - monthIndex(startDate) + 1 30 if months < 1 { 31 return 1 32 } 33 34 return months 35 } 36 37 // FormatShortMonthYear is "feb 2026". 38 func FormatShortMonthYear(t time.Time) string { 39 return strings.ToLower(t.Format("Jan 2006")) 40 } 41 42 // FormatShortEndDate is "present" for a current role, else "feb 2026". 43 func FormatShortEndDate(endDate *time.Time, current bool) string { 44 if current || endDate == nil { 45 return "present" 46 } 47 return FormatShortMonthYear(*endDate) 48 } 49 50 // FormatRange is "oct 2024 – feb 2026" or "feb 2026 – present". 51 func FormatRange(startDate time.Time, endDate *time.Time, current bool) string { 52 return FormatShortMonthYear(startDate) + " – " + FormatShortEndDate(endDate, current) 53 } 54 55 // FormatDurationShort is "1 yr 5 mo", "2 yr" or "9 mo". 56 func FormatDurationShort(startDate time.Time, endDate *time.Time, current bool) string { 57 months := MonthsDuration(startDate, endDate, current) 58 years, rest := months/12, months%12 59 switch { 60 case years == 0: 61 return fmt.Sprintf("%d mo", rest) 62 case rest == 0: 63 return fmt.Sprintf("%d yr", years) 64 default: 65 return fmt.Sprintf("%d yr %d mo", years, rest) 66 } 67 } 68 69 // MonthStamp is the machine-readable month for <time datetime>: "2026-02". 70 func MonthStamp(t time.Time) string { 71 return t.Format("2006-01") 72 } 73 74 // EndStamp is MonthStamp of a role's last month (this month while current). 75 func EndStamp(endDate *time.Time, current bool) string { 76 return MonthStamp(spanEnd(endDate, current)) 77 } 78 79 // Span is one role on a timeline. 80 type Span struct { 81 Start time.Time 82 End *time.Time 83 Current bool 84 } 85 86 // Tick is a year boundary on a timeline axis, At in [0,1]. 87 type Tick struct { 88 Label string 89 At float64 90 } 91 92 // Axis maps months onto [0,1]: from january of the earliest start year to 93 // the end of the latest month any role reaches (this month while one is 94 // current). A role fills its start month through its end month. 95 type Axis struct { 96 first, months int 97 Ticks []Tick 98 // EndLabel marks the right edge: "now" while a role is current, unless 99 // the last year tick sits too close to it to fit both labels. 100 EndLabel string 101 // starts holds every role's start month: a role that ends in the month 102 // the next one starts hands off at that month's start instead of 103 // running through it and overlapping. 104 starts map[int]bool 105 } 106 107 func NewAxis(spans []Span) Axis { 108 if len(spans) == 0 { 109 return Axis{months: 1} 110 } 111 first, last := monthIndex(spans[0].Start), monthIndex(spanEnd(spans[0].End, spans[0].Current)) 112 current := false 113 for _, s := range spans { 114 first = min(first, monthIndex(s.Start)) 115 last = max(last, monthIndex(spanEnd(s.End, s.Current))) 116 current = current || s.Current 117 } 118 first -= first % 12 // january 119 a := Axis{first: first, months: max(last+1-first, 1), starts: map[int]bool{}} 120 for _, s := range spans { 121 a.starts[monthIndex(s.Start)] = true 122 } 123 for m := first; m < first+a.months; m += 12 { 124 a.Ticks = append(a.Ticks, Tick{Label: fmt.Sprint(m / 12), At: a.at(m)}) 125 } 126 if current && a.Ticks[len(a.Ticks)-1].At < 0.85 { 127 a.EndLabel = "now" 128 } 129 return a 130 } 131 132 func (a Axis) at(month int) float64 { 133 return float64(month-a.first) / float64(a.months) 134 } 135 136 // From is where a role's bar starts. 137 func (a Axis) From(startDate time.Time) float64 { 138 return a.at(monthIndex(startDate)) 139 } 140 141 // To is where a role's bar ends: the end of its last month, or its start 142 // when another role begins that month. 143 func (a Axis) To(endDate *time.Time, current bool) float64 { 144 m := monthIndex(spanEnd(endDate, current)) 145 if !current && a.starts[m] { 146 return a.at(m) 147 } 148 return a.at(m + 1) 149 }