oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import "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).
18type stepState uint8
19
20const (
21 stepIncomplete stepState = iota
22 stepCandidate
23 stepExhausted
24)
25
26func 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
40func 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.
100func 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.
105func 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.
129func 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
151func 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
156func 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.
169func civil(t time.Time) time.Time {
170 return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
171}
172
173func 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).
182func daysBetween(start, end time.Time) int {
183 return int(civilDay(end) - civilDay(start))
184}
185