| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sort" |
| 6 | "strings" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // MergeWindows returns a copy sorted Sunday-first then by start, with |
| 11 | // windows on the same day that overlap or touch joined into one. |
| 12 | // |
| 13 | // Touching counts: "9-12, 12-1" is one stretch of availability, and |
| 14 | // leaving it as two would make the slot arithmetic offer a 12:00 slot |
| 15 | // twice, or a 60-minute slot at 11:30 not at all, depending on which |
| 16 | // window it happened to be looking through. The input is not changed. |
| 17 | func MergeWindows(ws []Window) []Window { |
| 18 | if len(ws) == 0 { |
| 19 | return nil |
| 20 | } |
| 21 | sorted := make([]Window, len(ws)) |
| 22 | copy(sorted, ws) |
| 23 | sort.SliceStable(sorted, func(i, j int) bool { |
| 24 | if sorted[i].Weekday != sorted[j].Weekday { |
| 25 | return sorted[i].Weekday < sorted[j].Weekday |
| 26 | } |
| 27 | return sorted[i].StartMin < sorted[j].StartMin |
| 28 | }) |
| 29 | out := []Window{sorted[0]} |
| 30 | for _, next := range sorted[1:] { |
| 31 | cur := &out[len(out)-1] |
| 32 | if next.Weekday == cur.Weekday && next.StartMin <= cur.EndMin { |
| 33 | if next.EndMin > cur.EndMin { |
| 34 | cur.EndMin = next.EndMin |
| 35 | } |
| 36 | continue |
| 37 | } |
| 38 | out = append(out, next) |
| 39 | } |
| 40 | return out |
| 41 | } |
| 42 | |
| 43 | // DescribeWindows writes the windows out in words, one line per day |
| 44 | // that has any, Sunday first: "Monday 12:00–15:00, 17:00–19:00". |
| 45 | // |
| 46 | // This is what the pane prints, and it is the CONTRACT of the parser |
| 47 | // in the same way the quick-add pane is: whatever the sentence was |
| 48 | // read to mean is shown here, in full, before it can be saved. |
| 49 | func DescribeWindows(ws []Window) []string { |
| 50 | byDay := map[time.Weekday][]string{} |
| 51 | for _, w := range MergeWindows(ws) { |
| 52 | byDay[w.Weekday] = append(byDay[w.Weekday], |
| 53 | fmt.Sprintf("%s–%s", clockText(w.StartMin), clockText(w.EndMin))) |
| 54 | } |
| 55 | var lines []string |
| 56 | for d := time.Sunday; d <= time.Saturday; d++ { |
| 57 | if spans := byDay[d]; len(spans) > 0 { |
| 58 | lines = append(lines, d.String()+" "+strings.Join(spans, ", ")) |
| 59 | } |
| 60 | } |
| 61 | return lines |
| 62 | } |
| 63 | |
| 64 | // clockText prints minutes from midnight as HH:MM. 1440 is "24:00": |
| 65 | // the end of the day, said the way the host typed it, never wrapped to |
| 66 | // a "00:00" that reads as the start of the same day. |
| 67 | func clockText(minutes int) string { |
| 68 | return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60) |
| 69 | } |
| 70 | |
| 71 | // BookingPaneView is the availability pane: the windows in words and |
| 72 | // every problem, already formatted so the template does no work. |
| 73 | type BookingPaneView struct { |
| 74 | Lines []string |
| 75 | Problems []string |
| 76 | Empty bool |
| 77 | } |
| 78 | |
| 79 | // bookingPaneFor renders the sentence for the pane. It is what the |
| 80 | // live fetch and the scriptless 422 both show, so there is exactly one |
| 81 | // description of what a sentence was read to mean. |
| 82 | func bookingPaneFor(text string) BookingPaneView { |
| 83 | if strings.TrimSpace(text) == "" { |
| 84 | return BookingPaneView{Empty: true} |
| 85 | } |
| 86 | windows, problems := ParseAvailability(text) |
| 87 | return BookingPaneView{Lines: DescribeWindows(windows), Problems: problems} |
| 88 | } |
| 89 | |