prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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 }