| 1 | package calendar |
| 2 | |
| 3 | import "time" |
| 4 | |
| 5 | // The stepping half of the recurrence engine, split from occurrences.go |
| 6 | // so neither file passes the 300-line cap. Expand (there) walks; these |
| 7 | // decide where the next step lands. |
| 8 | |
| 9 | // advance steps to the next candidate start in wall clock, skipping |
| 10 | // dates a rule names but a month does not have. |
| 11 | // |
| 12 | // Every candidate is rebuilt from the series' ORIGINAL clock — proto's |
| 13 | // hour and minute on the new civil date — never from cur's. cur is the |
| 14 | // previous occurrence as it RESOLVED, and on the spring-forward day a |
| 15 | // 01:30 resolves to 02:30; stepping from that with AddDate would carry |
| 16 | // 02:30 forward for the rest of the series' life while the record |
| 17 | // still said 01:30 (booking spec §4.1, r7 f1). |
| 18 | type stepState uint8 |
| 19 | |
| 20 | const ( |
| 21 | stepIncomplete stepState = iota |
| 22 | stepCandidate |
| 23 | stepExhausted |
| 24 | ) |
| 25 | |
| 26 | func advance(cur, proto time.Time, r Rule, loc *time.Location) (time.Time, stepState) { |
| 27 | if !validExpansionRule(r) || !inDateHorizon(cur) || !inDateHorizon(proto) { |
| 28 | return time.Time{}, stepIncomplete |
| 29 | } |
| 30 | if r.Freq == "" { |
| 31 | return time.Time{}, stepExhausted |
| 32 | } |
| 33 | next, state := nextCandidate(cur, proto, r, loc) |
| 34 | if state == stepCandidate && (!inDateHorizon(next) || !next.After(cur)) { |
| 35 | return time.Time{}, stepIncomplete |
| 36 | } |
| 37 | return next, state |
| 38 | } |
| 39 | |
| 40 | func nextCandidate(cur, proto time.Time, r Rule, loc *time.Location) (time.Time, stepState) { |
| 41 | switch r.Freq { |
| 42 | case FreqDaily: |
| 43 | if int64(r.Interval) > lastCivilDay-civilDay(cur) { |
| 44 | return time.Time{}, stepIncomplete |
| 45 | } |
| 46 | return candidateOnDay(civilDay(cur)+int64(r.Interval), proto, loc) |
| 47 | case FreqWeekly: |
| 48 | day := civilDay(cur) |
| 49 | if len(r.ByDay) == 0 { |
| 50 | if int64(r.Interval) > (lastCivilDay-day)/7 { |
| 51 | return time.Time{}, stepIncomplete |
| 52 | } |
| 53 | return candidateOnDay(day+7*int64(r.Interval), proto, loc) |
| 54 | } |
| 55 | k := daysSinceWeekStart(cur.Weekday(), r.WeekStart) |
| 56 | for off := 1; off <= 6-k; off++ { |
| 57 | if containsWeekday(r.ByDay, time.Weekday((int(cur.Weekday())+off)%7)) { |
| 58 | return candidateOnDay(day+int64(off), proto, loc) |
| 59 | } |
| 60 | } |
| 61 | weekStart := day - int64(k) |
| 62 | if int64(r.Interval) > (lastCivilDay-weekStart)/7 { |
| 63 | return time.Time{}, stepIncomplete |
| 64 | } |
| 65 | weekStart += 7 * int64(r.Interval) |
| 66 | for off := 0; off < 7; off++ { |
| 67 | if containsWeekday(r.ByDay, time.Weekday((int(r.WeekStart)+off)%7)) { |
| 68 | return candidateOnDay(weekStart+int64(off), proto, loc) |
| 69 | } |
| 70 | } |
| 71 | case FreqMonthly: |
| 72 | currentMonth := cur.Year()*12 + int(cur.Month()) - 1 |
| 73 | for step := 1; step <= 4800; step++ { |
| 74 | if r.Interval > (9999*12+11-currentMonth)/step { |
| 75 | return time.Time{}, stepIncomplete |
| 76 | } |
| 77 | target := currentMonth + step*r.Interval |
| 78 | base := time.Date(target/12, time.Month(target%12+1), 1, 0, 0, 0, 0, time.UTC) |
| 79 | if d, ok := monthlyDate(base, r, proto, loc); ok { |
| 80 | return d, stepCandidate |
| 81 | } |
| 82 | } |
| 83 | return time.Time{}, stepExhausted |
| 84 | case FreqYearly: |
| 85 | for step := 1; step <= 400; step++ { |
| 86 | if r.Interval > (9999-cur.Year())/step { |
| 87 | return time.Time{}, stepIncomplete |
| 88 | } |
| 89 | y := cur.Year() + step*r.Interval |
| 90 | if proto.Day() <= daysInMonth(y, proto.Month()) { |
| 91 | return time.Date(y, proto.Month(), proto.Day(), proto.Hour(), proto.Minute(), 0, 0, loc), stepCandidate |
| 92 | } |
| 93 | } |
| 94 | return time.Time{}, stepExhausted |
| 95 | } |
| 96 | return time.Time{}, stepIncomplete |
| 97 | } |
| 98 | |
| 99 | // onDate puts proto's time of day onto d's civil date in loc. |
| 100 | func onDate(d, proto time.Time, loc *time.Location) time.Time { |
| 101 | return time.Date(d.Year(), d.Month(), d.Day(), proto.Hour(), proto.Minute(), 0, 0, loc) |
| 102 | } |
| 103 | |
| 104 | // monthlyDate resolves a monthly rule inside the month base sits in. |
| 105 | func monthlyDate(base time.Time, r Rule, proto time.Time, loc *time.Location) (time.Time, bool) { |
| 106 | y, m := base.Year(), base.Month() |
| 107 | hh, mm := proto.Hour(), proto.Minute() |
| 108 | |
| 109 | switch { |
| 110 | case r.Ordinal != 0: |
| 111 | return nthWeekday(y, m, r.Ordinal, r.OrdinalDay, hh, mm, loc) |
| 112 | case r.MonthDay > 0: |
| 113 | if r.MonthDay > daysInMonth(y, m) { |
| 114 | return time.Time{}, false // no 31st this month: skip it |
| 115 | } |
| 116 | return time.Date(y, m, r.MonthDay, hh, mm, 0, 0, loc), true |
| 117 | default: |
| 118 | // No BY part: the same day-of-month as DTSTART. |
| 119 | if proto.Day() > daysInMonth(y, m) { |
| 120 | return time.Time{}, false |
| 121 | } |
| 122 | return time.Date(y, m, proto.Day(), hh, mm, 0, 0, loc), true |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // nthWeekday finds "the 2nd Friday" (n>0) or "the last Friday" (n<0) of |
| 127 | // a month, reporting false when the month has no such day — a fifth |
| 128 | // Friday exists in some months and not others. |
| 129 | func nthWeekday(y int, m time.Month, n int, wd time.Weekday, hh, mm int, loc *time.Location) (time.Time, bool) { |
| 130 | if n == 0 || n > 5 || n < -5 { |
| 131 | return time.Time{}, false |
| 132 | } |
| 133 | if n > 0 { |
| 134 | first := time.Date(y, m, 1, 0, 0, 0, 0, time.UTC) |
| 135 | delta := (int(wd) - int(first.Weekday()) + 7) % 7 |
| 136 | day := 1 + delta + (n-1)*7 |
| 137 | if day > daysInMonth(y, m) { |
| 138 | return time.Time{}, false |
| 139 | } |
| 140 | return time.Date(y, m, day, hh, mm, 0, 0, loc), true |
| 141 | } |
| 142 | last := time.Date(y, m, daysInMonth(y, m), 0, 0, 0, 0, time.UTC) |
| 143 | delta := (int(last.Weekday()) - int(wd) + 7) % 7 |
| 144 | day := last.Day() - delta + (n+1)*7 |
| 145 | if day < 1 { |
| 146 | return time.Time{}, false |
| 147 | } |
| 148 | return time.Date(y, m, day, hh, mm, 0, 0, loc), true |
| 149 | } |
| 150 | |
| 151 | func daysInMonth(y int, m time.Month) int { |
| 152 | // Day 0 of the next month is the last day of this one. |
| 153 | return time.Date(y, m+1, 0, 0, 0, 0, 0, time.UTC).Day() |
| 154 | } |
| 155 | |
| 156 | func containsWeekday(days []time.Weekday, wd time.Weekday) bool { |
| 157 | for _, d := range days { |
| 158 | if d == wd { |
| 159 | return true |
| 160 | } |
| 161 | } |
| 162 | return false |
| 163 | } |
| 164 | |
| 165 | // civil is t's calendar date with no zone at all: the same date at |
| 166 | // UTC midnight. Arithmetic on two of these counts DAYS, whatever the |
| 167 | // zone's offsets did in between — a week across the spring transition |
| 168 | // is 167 elapsed hours and still seven days. |
| 169 | func civil(t time.Time) time.Time { |
| 170 | return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) |
| 171 | } |
| 172 | |
| 173 | func daysSinceWeekStart(d, wkst time.Weekday) int { |
| 174 | return (int(d) - int(wkst) + 7) % 7 |
| 175 | } |
| 176 | |
| 177 | // daysBetween is the whole-day span from a start to an end, used to |
| 178 | // carry an event's length across occurrences in wall clock. Civil |
| 179 | // dates, never elapsed hours: 28 March 09:00 to 30 March 09:00 in |
| 180 | // Dublin is 47 hours and TWO days, and dividing by 24 made it one, so |
| 181 | // every later occurrence ended a day early (booking spec §4.1, r4 B2). |
| 182 | func daysBetween(start, end time.Time) int { |
| 183 | return int(civilDay(end) - civilDay(start)) |
| 184 | } |
| 185 | |