Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions calendarium/liturgics/day.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@
Tradition.Greek: GreekYear,
}

# How many commemorations Day.minimal_saints keeps before truncating --
# purely a display-space constraint (the monthly calendar grid cell,
# summary_title's fallback), not a statement about which commemorations
# matter more. Adjust freely if the grid's cell size changes.
# How many commemorations Day.minimal_saints keeps -- a display-space
# constraint (the monthly calendar grid cell, summary_title's fallback).
# Which ones it keeps is _display_priority's job. Adjust freely if the
# grid's cell size changes.
MINIMAL_SAINTS_LIMIT = 3


Expand Down Expand Up @@ -108,6 +108,24 @@ def _speech_worthy(dc):
return _has_story(dc) or dc.tradition != 'greek'


def _display_priority(dc):
"""Sort key for which commemorations win Day.minimal_saints' few slots.

Ranked first -- a typikon service rank (rank > 0) or abbamoses's dagger
(high_rank), treated as one tier because the two don't agree with each
other and rank's 2/3/4 isn't an importance scale (see the Fast-follow
section of docs/saint-model-refactor.md). Then anything with a story,
then the rest. Day.saints order is the tie-break (sorted() is stable).

Without this, day_native rows always come before additive ones, so a
bare harvested name ("Martyr Mertios") took the slot of a storied saint
kept as an additive row (Benedict Biscop, St Patrick)."""

if dc.rank > 0 or dc.high_rank:
return 0
return 1 if _has_story(dc) else 2


def _merge_tradition_days(rows, tradition):
"""Reduce the rows for a day to one per `(pdist, month, day)` slot.

Expand Down Expand Up @@ -444,28 +462,32 @@ async def _add_supplemental_commemorations(self):
# itself -- it's consumed as plain strings elsewhere (ical.py, RSS).
self.saint_links = []
self.spoken_saints = []
shown = [] # the rows behind self.saints, in the same order
for dcs in day_native_by_day.values():
# Grouped by whichever Day row the entries originally came from
# (dc.day_id may not be in self.days -- see the class docstring
# on _add_supplemental_commemorations), not by self.days, since a
# shared saint can be attached to a Day row that lost the
# feast-level-facts preference for this tradition.
titles = [dc.title for dc in dcs]
shown.extend(dcs)
self.saints.extend(titles)
self.saint_links.extend((dc.title, dc.id if _has_story(dc) else None) for dc in dcs)
self.spoken_saints.extend(dc.title for dc in dcs if _speech_worthy(dc))

shown.extend(additive)
self.saints.extend(dc.title for dc in additive)
self.saint_links.extend((dc.title, dc.id if _has_story(dc) else None) for dc in additive)
self.spoken_saints.extend(dc.title for dc in additive if _speech_worthy(dc))

# A length-capped view of self.saints for space-constrained displays
# (the monthly calendar grid, and summary_title's fallback below) --
# deliberately just a truncation, not a day_native/story-provenance
# distinction (that was the old design, and it broke down as soon as
# a "story-only" commemoration needed to be the thing shown, e.g.
# after a feast_name/DayCommemoration de-duplication).
self.minimal_saints = self.saints[:MINIMAL_SAINTS_LIMIT]
# (the monthly calendar grid, and summary_title's fallback below),
# keeping the most significant commemorations -- see _display_priority.
# It ranks rows rather than filtering them: the old design showed only
# day_native rows here, and broke down as soon as a "story-only"
# commemoration needed to be the thing shown, e.g. after a
# feast_name/DayCommemoration de-duplication.
self.minimal_saints = [dc.title for dc in sorted(shown, key=_display_priority)[:MINIMAL_SAINTS_LIMIT]]

# spoken_saints excludes only the story-less tradition='greek' overlay
# (the bulk antiochian.org-harvested commemorations, see
Expand Down
22 changes: 16 additions & 6 deletions calendarium/tests/data/january.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"fast_abstentions": [],
"saints": [
"Our Father among the Saints Basil the Great (379).",
"Telemachos the Martyr",
"St Gregory, Bishop of Nazianzus (374), father of St Gregory the Theologian"
],
"service_notes": null,
Expand Down Expand Up @@ -293,6 +294,8 @@
"Synaxis of Seventy Apostles",
"Our Righteous Father Theoctistus, Abbot of Cucomo in Sicily",
"Nikephoros the Leper",
"New Martyr Onuphrios",
"Righteous Euthymios of Vatopedi",
"Eustathios Archbishop of Serbia",
"The Ethiopian Eunuch of Queen Candace",
"Venerable Apollinaria (5th c.)"
Expand Down Expand Up @@ -809,6 +812,7 @@
"saints": [
"Ven. George the Chozebite",
"Gregory of the Kiev Caves",
"Emilianos the Confessor, Bishop of Cyzikos",
"Our Venerable Mother Domnica (Domnina) (ca. 474)",
"Saint Atticus, Patriarch of Constantinople (425)",
"Saint Severinus (482)"
Expand Down Expand Up @@ -924,7 +928,7 @@
"titles": [
"Monday of the 30th week after Pentecost"
],
"summary_title": "St Gregory of Nyssa; Dometian, Bishop of Melitene; St Theophan the Recluse",
"summary_title": "St Gregory of Nyssa; St Theophan the Recluse; Dometian, Bishop of Melitene",
"feast_level": 4,
"feast_level_description": "Red cross (polyeleos typikon symbol)",
"feasts": null,
Expand Down Expand Up @@ -1088,6 +1092,7 @@
],
"saints": [
"St Sava, Archbishop of Serbia",
"Martyr Mertios",
"Holy Martyr Tatiana (ca. 230)",
"Venerable Benedict Biscop, Abbot of Wearmouth (689-690)"
],
Expand Down Expand Up @@ -1233,7 +1238,7 @@
"titles": [
"Saturday of the 30th week after Pentecost"
],
"summary_title": "Ven. Paul of Thebes and John Calabytes; Ven. Pansophius of Alexandria, the Martyr; Our Holy Father John Kalyvites (the hut-dweller) (ca. 450)",
"summary_title": "Ven. Paul of Thebes and John Calabytes; Our Holy Father John Kalyvites (the hut-dweller) (ca. 450); Saint Ita of Kileedy, Ireland (570)",
"feast_level": 2,
"feast_level_description": "Black squigg (6-stich typikon symbol)",
"feasts": null,
Expand Down Expand Up @@ -1655,6 +1660,7 @@
],
"saints": [
"Ven. Maximus the Confessor",
"Neophytos the Martyr of Nicaea",
"Holy Martyr Agnes of Rome (ca. 304)",
"Our Holy Father Maximos the Greek (1556)"
],
Expand Down Expand Up @@ -1753,7 +1759,8 @@
"fast_exception_desc": "",
"fast_abstentions": [],
"saints": [
"Hieromartyr Clement and Martyr Agathangel"
"Hieromartyr Clement and Martyr Agathangel",
"Commemoration of the Holy Fathers of the Sixth Ecumenical Council"
],
"service_notes": null,
"abbreviated_reading_indices": [
Expand Down Expand Up @@ -1810,6 +1817,7 @@
"saints": [
"Ven. Xenia of Rome",
"Bl. Xenia of St Petersburg",
"Vavylas the Holy Martyr",
"Our Holy Father Macedonian (ca. 430)"
],
"service_notes": null,
Expand Down Expand Up @@ -1942,7 +1950,7 @@
"titles": [
"Wednesday of the 32nd week after Pentecost"
],
"summary_title": "Ven. Xenophon, Mary, and their sons Arcadius and John; St Simeon the Elder of Mount Sinai; St Ammonas of Egypt, disciple of St Anthony the Great (350)",
"summary_title": "Ven. Xenophon, Mary, and their sons Arcadius and John; St Ammonas of Egypt, disciple of St Anthony the Great (350); Our Holy Mother Paula of Rome (404)",
"feast_level": 0,
"feast_level_description": "Liturgy",
"feasts": null,
Expand Down Expand Up @@ -2094,7 +2102,7 @@
"titles": [
"Friday of the 32nd week after Pentecost"
],
"summary_title": "Ven. Ephrem the Syrian; Theodosius of Totma; Our Holy Father Isaac the Syrian, bishop of Nineveh (7th c.)",
"summary_title": "Ven. Ephrem the Syrian; Our Holy Father Isaac the Syrian, bishop of Nineveh (7th c.); Palladios the Hermit of Antioch",
"feast_level": 2,
"feast_level_description": "Black squigg (6-stich typikon symbol)",
"feasts": null,
Expand All @@ -2112,6 +2120,7 @@
],
"saints": [
"Ven. Ephrem the Syrian",
"Palladios the Hermit of Antioch",
"Theodosius of Totma",
"Our Holy Father Isaac the Syrian, bishop of Nineveh (7th c.)"
],
Expand Down Expand Up @@ -2150,7 +2159,7 @@
"titles": [
"Saturday of the 32nd week after Pentecost"
],
"summary_title": "Trans. Rel. Ignatius the Godbearer; St Laurence, Recluse of the Kiev Caves; St Andrei Rublev, iconographer (1430)",
"summary_title": "Trans. Rel. Ignatius the Godbearer; St Andrei Rublev, iconographer (1430); Our Holy Father Aphrahat the Persian (4th c.)",
"feast_level": 2,
"feast_level_description": "Black squigg (6-stich typikon symbol)",
"feasts": null,
Expand Down Expand Up @@ -2309,6 +2318,7 @@
"fast_abstentions": [],
"saints": [
"Unmercenaries Cyrus and John",
"Our Righteous Father Arsenius of Parus",
"Venerable Nicetas, hermit of the Kiev Caves and Bishop of Novgorod (1108)",
"Marcella of Rome (410)"
],
Expand Down
55 changes: 47 additions & 8 deletions calendarium/tests/test_liturgics.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from .. import datetools, liturgics, models
from ..datetools import Tradition, Translation
from ..liturgics.day import _has_story
from ..liturgics.day import _display_priority, _has_story
from bible.models import Verse


Expand Down Expand Up @@ -865,19 +865,58 @@ def test_real_content_is_a_story(self):
class TestDay(TestCase):
fixtures = ['calendarium.json', 'commemorations.json']

async def test_minimal_saints_is_a_plain_truncation(self):
"""minimal_saints exists purely for space-constrained displays (the
monthly calendar grid, summary_title's fallback) -- it should be
nothing more than the first MINIMAL_SAINTS_LIMIT entries of the full
list, regardless of day_native/story provenance. Oct 1 Greek has 6
commemorations; the first 3 should come through unchanged."""
async def test_minimal_saints_keeps_three_of_the_full_list(self):
"""Oct 1 Greek has 6 commemorations; minimal_saints keeps 3 of them."""

day = liturgics.Day(2026, 10, 1, tradition=Tradition.Greek)
await day.ainitialize()

self.assertEqual(len(day.saints), 6)
self.assertEqual(day.minimal_saints, day.saints[:3])
self.assertEqual(len(day.minimal_saints), 3)
self.assertLessEqual(set(day.minimal_saints), set(day.saints))

async def test_minimal_saints_prefers_storied_over_bare_names(self):
"""A bare harvested name must not take a storied saint's slot just
because it is day_native and the storied one is additive."""

for month, day_, kept, bare in (
(1, 12, 'Venerable Benedict Biscop, Abbot of Wearmouth (689-690)', 'Martyr Mertios'),
(3, 17, 'St Patrick, Enlightener of Ireland (ca. 461)', 'Marinos the Martyr'),
):
with self.subTest(month=month, day=day_):
day = liturgics.Day(2026, month, day_)
await day.ainitialize()

self.assertIn(bare, day.saints)
self.assertIn(kept, day.minimal_saints)
self.assertNotIn(bare, day.minimal_saints)

def test_display_priority(self):
row = lambda rank=0, high_rank=False, story=None: SimpleNamespace(rank=rank, high_rank=high_rank, story=story)

self.assertEqual(_display_priority(row(rank=2)), 0)
self.assertEqual(_display_priority(row(high_rank=True)), 0)
self.assertEqual(_display_priority(row(story='<p>He was a deacon.</p>')), 1)
self.assertEqual(_display_priority(row(story='<p></p>')), 2)
self.assertEqual(_display_priority(row()), 2)

async def test_sixth_council_date_differs_by_tradition(self):
"""The same council, kept on different days: Jan 23 in Slavic
practice (oca.org), Sep 14 in Greek (antiochian.org). Neither date
may leak into the other tradition."""

for tradition, month, day_, expected in (
(Tradition.Slavic, 1, 23, True),
(Tradition.Greek, 1, 23, False),
(Tradition.Greek, 9, 14, True),
(Tradition.Slavic, 9, 14, False),
):
with self.subTest(tradition=tradition, month=month, day=day_):
day = liturgics.Day(2026, month, day_, tradition=tradition)
await day.ainitialize()

self.assertEqual(any('Sixth Ecumenical Council' in s or '6th Ecumenical Council' in s
for s in day.saints), expected)

async def test_minimal_saints_untruncated_when_short(self):
day = liturgics.Day(2026, 1, 7)
Expand Down
Loading