package report import ( "fmt" "time" ) // Period is the window a report covers: an inclusive range of ISO dates, with // either side empty for unbounded. Dates are stored as YYYY-MM-DD, so comparing // them as strings compares them as dates and nothing has to be parsed to filter // on one. type Period struct { // Label is what to call the window: "last month", "all time", "2026-06". Label string // Span names the dates Label stands for, because "last month" says nothing // on its own about which month it was. Empty when the label already is the // span, as it is for a single month and for all time. Span string From string To string } // String names the window, and what it covers when the label does not. func (p Period) String() string { if p.Span == "" { return p.Label } return p.Label + " · " + p.Span } // LastMonth is the label of the window a report opens on. The month that has // just ended is the last one a statement can be complete for: this month is // still filling up, so its total is not comparable with any other. const LastMonth = "last month" // Periods is the axis a report steps along, widest window first: the named // ranges, then every month the index holds that is older than last month, most // recent of those first. months is expected newest first, as store.Months // returns it; anything in it that a named range already covers is left out of // the tail rather than offered twice. // // The named ranges are relative to today, not to the newest statement. "Last // month" means the month that has just ended, so if no statement covers it yet // the report for it is empty — which is the honest answer, and one the user can // act on, where silently reporting an older month they did not ask for is not. func Periods(today time.Time, months []string) []Period { thisMonth := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) lastMonth := thisMonth.AddDate(0, -1, 0) // A range ends with this month rather than with the last complete one: // "last 3 months" is asked in order to see what is happening now, and // leaving out the days since the 1st would answer a different question. rolling := func(back int, label string) Period { from := thisMonth.AddDate(0, -back, 0) return Period{ Label: label, Span: from.Format("2006-01") + " … " + thisMonth.Format("2006-01"), From: isoDate(from), To: isoDate(endOfMonth(thisMonth)), } } month := func(start time.Time, label string) Period { p := Period{Label: label, From: isoDate(start), To: isoDate(endOfMonth(start))} if label != start.Format("2006-01") { p.Span = start.Format("2006-01") } return p } year := time.Date(today.Year(), time.January, 1, 0, 0, 0, 0, time.UTC) out := []Period{ {Label: "all time"}, { Label: "this year", Span: fmt.Sprintf("%d", today.Year()), From: isoDate(year), To: isoDate(year.AddDate(1, 0, -1)), }, rolling(11, "last 12 months"), rolling(2, "last 3 months"), month(thisMonth, "this month"), month(lastMonth, LastMonth), } cutoff := lastMonth.Format("2006-01") for _, ym := range months { // Months at or after last month are already on the axis above, and one // dated in the future is a parsing bug rather than a window to offer. if ym >= cutoff { continue } start, err := time.Parse("2006-01", ym) if err != nil { continue } out = append(out, month(start, ym)) } return out } // DefaultIndex is where on the axis a report opens. func DefaultIndex(ps []Period) int { for i, p := range ps { if p.Label == LastMonth { return i } } return 0 } func isoDate(t time.Time) string { return t.Format("2006-01-02") } // endOfMonth takes the first of a month to its last day, whatever its length. func endOfMonth(first time.Time) time.Time { return first.AddDate(0, 1, -1) }