summaryrefslogtreecommitdiff
path: root/internal/calendar/temporal_ef.go
blob: 9e7e7b2669aa8c584c451f2029e6b18ee62f6647 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
package calendar

import (
	"strconv"
	"strings"
	"time"
)

// Extraordinary Form (1962) season constants. Advent/Christmas/Lent/Easter are
// shared with the OF; these are EF-specific.
const (
	Septuagesima       Season = "septuagesima"
	Passiontide        Season = "passiontide"
	TimeAfterEpiphany  Season = "time-after-epiphany"
	TimeAfterPentecost Season = "time-after-pentecost"
)

// efChristTheKing is the last Sunday of October (the 1962 date — unlike the OF's
// last Sunday before Advent).
func efChristTheKing(year int) time.Time {
	oct31 := time.Date(year, 10, 31, 0, 0, 0, 0, time.UTC)
	return oct31.AddDate(0, 0, -int(oct31.Weekday())) // Sunday on/before Oct 31
}

// efColour is the default (Sunday/ferial) colour of an EF season.
func efColour(s Season) Colour {
	switch s {
	case Advent, Septuagesima, Lent, Passiontide:
		return Violet
	case Christmas, Easter_:
		return White
	default: // TimeAfterEpiphany, TimeAfterPentecost
		return Green
	}
}

// efSeason returns the 1962 season for date, by chronological date boundaries.
func efSeason(date time.Time, y int, easter time.Time) Season {
	adventThis := adventStart(y)
	christmasThis := time.Date(y, 12, 25, 0, 0, 0, 0, time.UTC)
	jan6 := time.Date(y, 1, 6, 0, 0, 0, 0, time.UTC)
	septStart := easter.AddDate(0, 0, -63) // Septuagesima Sunday
	ashWed := easter.AddDate(0, 0, -46)
	passionSun := easter.AddDate(0, 0, -14)
	paschalEnd := easter.AddDate(0, 0, 55) // Saturday after Pentecost; Trinity = +56

	switch {
	case !date.Before(adventThis) && date.Before(christmasThis):
		return Advent
	case !date.Before(christmasThis): // Dec 25..31
		return Christmas
	case date.Before(jan6): // Jan 1..5
		return Christmas
	case date.Before(septStart): // Epiphany .. before Septuagesima
		return TimeAfterEpiphany
	case date.Before(ashWed):
		return Septuagesima
	case date.Before(passionSun):
		return Lent
	case date.Before(easter):
		return Passiontide
	case !date.After(paschalEnd):
		return Easter_
	default:
		return TimeAfterPentecost
	}
}

func efCel(season Season, slug string, col Colour, rank Rank, week int) temporalDay {
	return temporalDay{
		Season: season, Colour: col, Week: week, Rank: rank,
		Sunday: false,
		Cel:    Celebration{Slug: slug, Rank: rank, Colour: col, Layer: "temporal"},
	}
}

// temporalEF computes the 1962 temporal identity of date. Season is exact
// (oracle-validated); ranks/weeks are best-effort for display.
func temporalEF(date time.Time) temporalDay {
	date = date.UTC().Truncate(24 * time.Hour)
	y := date.Year()
	easter := Easter(y)
	season := efSeason(date, y, easter)
	col := efColour(season)
	// The Octave of Pentecost (Whit Monday through the Ember Saturday, easter+50
	// to +55) is red, not the white of the rest of Paschaltide.
	if !date.Before(easter.AddDate(0, 0, 50)) && !date.After(easter.AddDate(0, 0, 55)) {
		col = Red
	}
	sun := date.Weekday() == time.Sunday

	// Major feasts of the Lord (I class): nice titles + colour.
	switch {
	case date.Month() == time.December && date.Day() == 25:
		return efCel(Christmas, "ef-nativity", White, RankClass1, 0)
	case date.Month() == time.January && date.Day() == 1:
		return efCel(Christmas, "ef-circumcision", White, RankClass1, 0)
	case date.Month() == time.January && date.Day() == 6:
		return efCel(TimeAfterEpiphany, "ef-epiphany", White, RankClass1, 0)
	case sameDay(date, easter.AddDate(0, 0, -46)):
		return efCel(Lent, "ef-ash-wednesday", Violet, RankClass1, 0)
	case sameDay(date, easter.AddDate(0, 0, -14)):
		return efCel(Passiontide, "ef-passion-sunday", Violet, RankClass1, 1)
	case sameDay(date, easter.AddDate(0, 0, -7)):
		return efCel(Passiontide, "ef-palm-sunday", Violet, RankClass1, 2)
	case sameDay(date, easter):
		return efCel(Easter_, "ef-easter-sunday", White, RankClass1, 1)
	case sameDay(date, easter.AddDate(0, 0, 7)):
		return efCel(Easter_, "ef-low-sunday", White, RankClass1, 2)
	case sameDay(date, easter.AddDate(0, 0, 39)):
		return efCel(Easter_, "ef-ascension", White, RankClass1, 0)
	case sameDay(date, easter.AddDate(0, 0, 49)):
		return efCel(Easter_, "ef-pentecost", Red, RankClass1, 0)
	case sameDay(date, easter.AddDate(0, 0, 56)):
		return efCel(TimeAfterPentecost, "ef-trinity", White, RankClass1, 1)
	case sameDay(date, easter.AddDate(0, 0, 60)):
		return efCel(TimeAfterPentecost, "ef-corpus-christi", White, RankClass1, 0)
	case sameDay(date, easter.AddDate(0, 0, 68)):
		return efCel(TimeAfterPentecost, "ef-sacred-heart", White, RankClass1, 0)
	case sameDay(date, efChristTheKing(y)):
		return efCel(TimeAfterPentecost, "ef-christ-the-king", White, RankClass1, 0)
	case sameDay(date, easter.AddDate(0, 0, 38)): // Wednesday: Vigil of the Ascension (II class)
		return efCel(Easter_, "ef-ascension-vigil", White, RankClass2, 0)
	case sameDay(date, easter.AddDate(0, 0, 48)): // Saturday: Vigil of Pentecost / Whitsun Eve (I class)
		return efCel(Easter_, "ef-pentecost-vigil", Red, RankClass1, 0)
	}

	// Sundays vs ferias of the current season.
	if sun {
		week := efWeek(date, season, y, easter)
		rank := RankClass2
		if season == Advent && sameDay(date, adventStart(y)) {
			rank = RankClass1 // 1st Sunday of Advent
		}
		return efCel(season, "ef-"+string(season)+"-sunday-"+strconv.Itoa(week), col, rank, week)
	}
	// The days between Ash Wednesday and the 1st Sunday of Lent have their own
	// proper Masses (not part of a numbered Lenten week).
	if season == Lent && date.Before(easter.AddDate(0, 0, -42)) {
		return efCel(Lent, "ef-lent-after-ashes-"+weekdayLower(date), Violet, RankClass3, 0)
	}
	week := efWeek(date, season, y, easter)
	rank := RankClass4 // per-annum feria
	if season == Advent || season == Lent || season == Passiontide {
		// III class ferias (1960 rubrics): Advent to Dec 16, and Lent/Passiontide.
		// These outrank III class feasts (the saint is only commemorated). The
		// Septuagesima season's ferias are IV class per annum, so a III class
		// saint (e.g. the Conversion of St Paul, Jan 25) displaces them.
		rank = RankClass3
	}
	if efPrivilegedFeria(date, easter) {
		// The days of Holy Week and the privileged octaves of Easter and
		// Pentecost are I class; no saint's feast is admitted (the feast is
		// transferred or, in the octaves, omitted). Ranking them class-1 makes
		// the temporal office win, matching the 1962 occurrence rules.
		rank = RankClass1
	}
	// The Ember Days of September and Advent (Wednesday, Friday, Saturday after
	// the third Sunday of the month/season) are II class privileged ferias in
	// violet; they displace a III class saint (only commemorated). The Lenten
	// Ember days are already III class Lenten ferias, and the Whitsun Ember days
	// fall inside the I class Pentecost octave handled above.
	if es, ok := efEmberSlug(date, y); ok {
		return efCel(season, es, Violet, RankClass2, week)
	}
	// Unique ferial slug (season-week-weekday) so proper ferias (Lent, Advent,
	// Holy Week, Ember days) can key their own readings; green-season ferias
	// simply have no lectionary entry and fall back to the Sunday.
	return efCel(season, "ef-"+string(season)+"-"+strconv.Itoa(week)+"-"+weekdayLower(date), col, rank, week)
}

// thirdSundayOfSeptember returns the third Sunday in September of year y.
func thirdSundayOfSeptember(y int) time.Time {
	d := time.Date(y, time.September, 1, 0, 0, 0, 0, time.UTC)
	for d.Weekday() != time.Sunday {
		d = d.AddDate(0, 0, 1)
	}
	return d.AddDate(0, 0, 14)
}

// efEmberSlug returns the Ember-day slug for date, if it is an Ember Wednesday,
// Friday or Saturday of September or Advent (the day after the third Sunday +
// 3/5/6). Returns ("", false) otherwise.
func efEmberSlug(date time.Time, y int) (string, bool) {
	for _, e := range []struct {
		third  time.Time
		season string
	}{
		{thirdSundayOfSeptember(y), "september"},
		{adventStart(y).AddDate(0, 0, 14), "advent"}, // 3rd Sunday of Advent
	} {
		switch {
		case sameDay(date, e.third.AddDate(0, 0, 3)):
			return "ef-" + e.season + "-ember-wed", true
		case sameDay(date, e.third.AddDate(0, 0, 5)):
			return "ef-" + e.season + "-ember-fri", true
		case sameDay(date, e.third.AddDate(0, 0, 6)):
			return "ef-" + e.season + "-ember-sat", true
		}
	}
	return "", false
}

func weekdayLower(d time.Time) string {
	return strings.ToLower(d.Weekday().String())
}

// efPrivilegedFeria reports whether date is an I class temporal weekday that
// impedes saints' feasts: the ferias of Holy Week (the six days before Easter)
// and the weekdays within the privileged octaves of Easter (Easter Mon–Sat) and
// Pentecost (Whit Mon–Sat). Easter/Pentecost Sundays and Low Sunday are already
// handled as named I class feasts above; this covers only their octave weekdays.
func efPrivilegedFeria(date, easter time.Time) bool {
	days := int(date.Sub(easter) / (24 * time.Hour))
	switch {
	case days >= -6 && days <= -1: // Monday..Saturday of Holy Week (incl. Triduum)
		return true
	case days >= 1 && days <= 6: // Easter octave weekdays
		return true
	case days >= 50 && days <= 55: // Pentecost octave weekdays (Pentecost = easter+49)
		return true
	}
	return false
}

// efWeek is a best-effort week-within-season number for display (not
// oracle-checked). Time after Pentecost counts Sundays from Pentecost.
func efWeek(date time.Time, season Season, y int, easter time.Time) int {
	switch season {
	case Advent:
		return daysBetween(adventStart(y), date)/7 + 1
	case TimeAfterPentecost:
		return daysBetween(easter.AddDate(0, 0, 49), date) / 7 // Sundays after Pentecost
	case Lent:
		return daysBetween(easter.AddDate(0, 0, -42), date)/7 + 1 // from 1st Sunday of Lent
	case TimeAfterEpiphany:
		jan6 := time.Date(y, 1, 6, 0, 0, 0, 0, time.UTC)
		return daysBetween(jan6, date)/7 + 1
	case Septuagesima:
		return daysBetween(easter.AddDate(0, 0, -63), date)/7 + 1
	case Easter_:
		return daysBetween(easter, date)/7 + 1
	default:
		return 0
	}
}