Skip to content

Commit 942f74b

Browse files
Copilotdavep
andauthored
Add forward_calendar config option for chronological calendar order
Agent-Logs-Url: https://github.com/davep/blogmore/sessions/4348fed1-bb82-4069-a053-ffba2a6bd485 Co-authored-by: davep <28237+davep@users.noreply.github.com>
1 parent b67054c commit 942f74b

10 files changed

Lines changed: 214 additions & 20 deletions

File tree

ChangeLog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
- Added a calendar view page (`with_calendar` / `--with-calendar`) that
1414
shows the full history of the blog as a year calendar.
1515
([#373](https://github.com/davep/blogmore/pull/373))
16+
- Added a `forward_calendar` configuration option that renders the calendar
17+
in natural chronological order (oldest to newest) instead of the default
18+
reverse-chronological order.
19+
([#373](https://github.com/davep/blogmore/pull/373))
1620

1721
## v2.10.0
1822

blogmore.yaml.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ posts_per_feed: 20
7272
# archive. A Calendar link is added to the navigation bar automatically.
7373
# with_calendar: false
7474

75+
# Optional: Use forward (oldest-to-newest) ordering in the calendar (default: false)
76+
# When true, the calendar runs in natural chronological order — oldest year at
77+
# the top, oldest month first within each year, and day numbers increasing left
78+
# to right (Monday first). When false (the default), everything is reversed so
79+
# the most recent content appears first. Configuration file only.
80+
# Only used when with_calendar is true.
81+
# forward_calendar: false
82+
7583
# Optional: Show estimated reading time on posts (default: false)
7684
# Displays the approximate time to read each post based on the configured WPM.
7785
# with_read_time: false

docs/configuration.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,19 @@ Generate a calendar view of all posts. When `true`, BlogMore generates a `calend
329329
with_calendar: true
330330
```
331331

332+
#### `forward_calendar`
333+
334+
Control the ordering of the calendar generated by [`with_calendar`](#with_calendar). When `false` (the default), the calendar is displayed in reverse chronological order — newest year at the top, newest month first within each year, and day numbers counting down from right to left within each row. When `true`, the calendar runs in natural chronological order — oldest year at the top, oldest month first within each year, and day numbers increasing left to right (Monday first), like a traditional wall calendar.
335+
336+
This is a **configuration file only** option — it cannot be set on the command line. Only meaningful when [`with_calendar`](#with_calendar) is `true`.
337+
338+
**Type:** Boolean
339+
**Default:** `false`
340+
341+
```yaml
342+
forward_calendar: true
343+
```
344+
332345
#### `with_read_time`
333346

334347
Show estimated reading time on each post. When enabled, BlogMore calculates the approximate time to read each post (based on the configured words-per-minute rate) and displays it next to the post date on all post listings and individual post pages.

docs/template-api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ below lists all variables that are available in every template.
4040
| `with_stats` | `bool` | `True` when the statistics page is enabled. |
4141
| `stats_url` | `str` | URL to the statistics page (respects `stats_path` and `clean_urls`). |
4242
| `with_calendar` | `bool` | `True` when the calendar page is enabled. |
43+
| `forward_calendar` | `bool` | `True` when the calendar is in forward (oldest-to-newest) order. |
4344
| `calendar_url` | `str` | URL to the calendar page (respects `calendar_path` and `clean_urls`). |
4445
| `theme_js_url` | `str` | URL to `theme.js` (with cache-bust query string). |
4546
| `search_js_url` | `str` | URL to `search.js` (with cache-bust query string). |

src/blogmore/calendar.py

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ class CalendarMonth:
6262
weeks: list[list[CalendarDay]]
6363
"""Calendar grid rows.
6464
65-
Each row contains exactly seven :class:`CalendarDay` items in
66-
Monday-to-Sunday order.
65+
Each row contains exactly seven :class:`CalendarDay` items. In
66+
reverse-chronological mode (the default) the order is Sunday-to-Monday so
67+
that day numbers count down left-to-right. In forward mode the order is
68+
Monday-to-Sunday, matching the usual calendar convention.
6769
"""
6870

6971

@@ -84,28 +86,40 @@ class CalendarYear:
8486
"""Whether any posts were published in this year."""
8587

8688
months: list[CalendarMonth]
87-
"""Months for this year in reverse chronological order (latest first)."""
89+
"""Months for this year.
90+
91+
In reverse-chronological mode (the default) the list runs newest-first.
92+
In forward mode the list runs oldest-first.
93+
"""
8894

8995

9096
def build_calendar(
9197
posts: list[Post],
9298
page1_suffix: str,
99+
*,
100+
forward: bool = False,
93101
) -> list[CalendarYear]:
94102
"""Build the calendar data structure from a list of posts.
95103
96-
Constructs a reverse-chronological list of :class:`CalendarYear` objects,
97-
each containing months in reverse order, spanning from the date of the
98-
first post to the date of the latest post.
104+
Constructs a list of :class:`CalendarYear` objects spanning from the
105+
date of the first post to the date of the latest post.
106+
107+
By default the list is in reverse chronological order (newest year
108+
first, newest month first, day numbers counting down from left to right
109+
within each row). Pass ``forward=True`` to generate a natural
110+
chronological calendar instead (oldest year first, Monday-to-Sunday
111+
columns, day numbers increasing left to right).
99112
100113
Args:
101114
posts: All published posts to include in the calendar.
102115
page1_suffix: The URL suffix for the first pagination page (e.g.
103116
``"index.html"`` or ``""`` for clean URLs).
117+
forward: When ``True``, generate the calendar in oldest-to-newest
118+
order. Defaults to ``False`` (reverse chronological).
104119
105120
Returns:
106-
A list of :class:`CalendarYear` objects in reverse chronological
107-
order (most recent year first). Returns an empty list when *posts*
108-
is empty or no posts have a date.
121+
A list of :class:`CalendarYear` objects. Returns an empty list
122+
when *posts* is empty or no posts have a date.
109123
"""
110124
dated_posts = [p for p in posts if p.date is not None]
111125
if not dated_posts:
@@ -138,7 +152,14 @@ def build_calendar(
138152

139153
years: list[CalendarYear] = []
140154

141-
for year in range(last_year, first_year - 1, -1):
155+
# Choose iteration direction for years and months.
156+
year_range = (
157+
range(first_year, last_year + 1)
158+
if forward
159+
else range(last_year, first_year - 1, -1)
160+
)
161+
162+
for year in year_range:
142163
# Determine the month range to include for this year.
143164
if year == last_year and year == first_year:
144165
year_last_month = last_month
@@ -158,24 +179,35 @@ def build_calendar(
158179

159180
months: list[CalendarMonth] = []
160181

161-
for month in range(year_last_month, year_first_month - 1, -1):
182+
month_range = (
183+
range(year_first_month, year_last_month + 1)
184+
if forward
185+
else range(year_last_month, year_first_month - 1, -1)
186+
)
187+
188+
for month in month_range:
162189
month_has_posts = (year, month) in months_with_posts
163190
month_name = dt.date(year, month, 1).strftime("%B")
164191
month_url: str | None = (
165192
f"/{year}/{month:02d}/{page1_suffix}" if month_has_posts else None
166193
)
167194

168-
# Build the week grid. ``monthdayscalendar`` returns weeks in
169-
# forward order (Mon first); reverse both the week list and each
170-
# week's day order so the most recent day appears first (top-left)
171-
# throughout — consistent with the overall reverse-chronological
172-
# ordering of the calendar.
173-
raw_weeks = list(reversed(month_calendar.monthdayscalendar(year, month)))
195+
# Build the week grid. ``monthdayscalendar`` always returns
196+
# weeks in forward order (Mon first). For reverse-chronological
197+
# mode, reverse both the week list and each week's day order so
198+
# the most recent day appears first (top-left); for forward mode,
199+
# keep them as-is so the grid reads like a normal calendar.
200+
raw_weeks = month_calendar.monthdayscalendar(year, month)
174201
weeks: list[list[CalendarDay]] = []
175202

176-
for raw_week in raw_weeks:
203+
ordered_weeks: list[list[int]] = (
204+
raw_weeks if forward else list(reversed(raw_weeks))
205+
)
206+
207+
for raw_week in ordered_weeks:
177208
week: list[CalendarDay] = []
178-
for day_num in reversed(raw_week):
209+
day_iter = raw_week if forward else list(reversed(raw_week))
210+
for day_num in day_iter:
179211
if day_num == 0:
180212
week.append(CalendarDay(date=None))
181213
else:

src/blogmore/generator.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ def _get_global_context(self) -> dict[str, Any]:
417417
"with_stats": self.site_config.with_stats,
418418
"stats_url": self._get_stats_url(),
419419
"with_calendar": self.site_config.with_calendar,
420+
"forward_calendar": self.site_config.forward_calendar,
420421
"calendar_url": self._get_calendar_url(),
421422
"with_read_time": self.site_config.with_read_time,
422423
"with_advert": self.site_config.with_advert,
@@ -1747,7 +1748,9 @@ def _generate_calendar_page(self, posts: list[Post], pages: list[Page]) -> None:
17471748
context["canonical_url"] = self._canonical_url_for_path(output_path)
17481749
# Determine page1_suffix for archive URL construction.
17491750
page1_suffix = self.site_config.page_1_path.lstrip("/")
1750-
calendar_years: list[CalendarYear] = build_calendar(posts, page1_suffix)
1751+
calendar_years: list[CalendarYear] = build_calendar(
1752+
posts, page1_suffix, forward=self.site_config.forward_calendar
1753+
)
17511754
html = self.renderer.render_calendar_page(
17521755
calendar_years=calendar_years, **context
17531756
)

src/blogmore/site_config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,21 @@ class SiteConfig:
106106
with_calendar: bool = False
107107
"""Whether to generate a calendar view of all posts."""
108108

109+
forward_calendar: bool = False
110+
"""Whether to display the calendar in forward (oldest-to-newest) order.
111+
112+
When ``False`` (the default) the calendar is rendered in reverse
113+
chronological order — newest year first, newest month first within each
114+
year, and day numbers counting down from right to left within each row.
115+
116+
When ``True`` the calendar is rendered in natural chronological order —
117+
oldest year first, oldest month first within each year, and day numbers
118+
running left to right in normal calendar order (Monday first).
119+
120+
This is a **configuration file only** option — it cannot be set on the
121+
command line. Only meaningful when :attr:`with_calendar` is ``True``.
122+
"""
123+
109124
minify_css: bool = False
110125
"""Whether to minify the CSS, writing it as ``styles.min.css``."""
111126

src/blogmore/templates/calendar.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,23 @@ <h3 class="calendar-month-label">
4040
</h3>
4141

4242
<div class="calendar-grid">
43+
{% if forward_calendar %}
44+
<div class="calendar-dow" aria-hidden="true">M</div>
45+
<div class="calendar-dow" aria-hidden="true">T</div>
46+
<div class="calendar-dow" aria-hidden="true">W</div>
47+
<div class="calendar-dow" aria-hidden="true">T</div>
48+
<div class="calendar-dow" aria-hidden="true">F</div>
49+
<div class="calendar-dow" aria-hidden="true">S</div>
50+
<div class="calendar-dow" aria-hidden="true">S</div>
51+
{% else %}
4352
<div class="calendar-dow" aria-hidden="true">S</div>
4453
<div class="calendar-dow" aria-hidden="true">S</div>
4554
<div class="calendar-dow" aria-hidden="true">F</div>
4655
<div class="calendar-dow" aria-hidden="true">T</div>
4756
<div class="calendar-dow" aria-hidden="true">W</div>
4857
<div class="calendar-dow" aria-hidden="true">T</div>
4958
<div class="calendar-dow" aria-hidden="true">M</div>
59+
{% endif %}
5060

5161
{% for week in cal_month.weeks %}
5262
{% for day in week %}

tests/test_calendar.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,82 @@ def test_calendar_year_fields(self) -> None:
336336
assert year.year_url == "/2024/index.html"
337337
assert year.has_posts is True
338338
assert year.months == []
339+
340+
341+
class TestBuildCalendarForward:
342+
"""Tests for build_calendar with forward=True (chronological order)."""
343+
344+
def test_forward_year_order_is_oldest_first(self) -> None:
345+
"""Years are listed oldest-first when forward=True."""
346+
posts = [_make_post(2022, 6, 1), _make_post(2024, 3, 15)]
347+
result = build_calendar(posts, "index.html", forward=True)
348+
years = [y.year for y in result]
349+
assert years == sorted(years)
350+
351+
def test_forward_month_order_is_oldest_first(self) -> None:
352+
"""Months within a year are listed oldest-first when forward=True."""
353+
posts = [_make_post(2024, 1, 10), _make_post(2024, 9, 5)]
354+
result = build_calendar(posts, "index.html", forward=True)
355+
months = [m.month for m in result[0].months]
356+
assert months == sorted(months)
357+
358+
def test_forward_days_increase_left_to_right(self) -> None:
359+
"""Day numbers increase left-to-right within a row when forward=True.
360+
361+
January 2024 week containing the 8th: Monday the 8th is at column 0.
362+
"""
363+
post = _make_post(2024, 1, 8)
364+
result = build_calendar([post], "index.html", forward=True)
365+
month = result[0].months[0]
366+
# Find the week containing the 8th.
367+
for week in month.weeks:
368+
for cell in week:
369+
if cell.date is not None and cell.date.day == 8:
370+
# Jan 8 2024 is a Monday — must be at column 0 (first).
371+
assert week.index(cell) == 0
372+
return
373+
raise AssertionError("Day 8 not found in calendar")
374+
375+
def test_forward_monday_at_column_zero(self) -> None:
376+
"""Monday appears at column 0 when forward=True.
377+
378+
January 2024: the 1st is a Monday, so the first week row should
379+
have January 1 at column index 0.
380+
"""
381+
post = _make_post(2024, 1, 1)
382+
result = build_calendar([post], "index.html", forward=True)
383+
month = result[0].months[0]
384+
first_week = month.weeks[0]
385+
assert first_week[0].date is not None
386+
assert first_week[0].date.day == 1
387+
388+
def test_forward_false_is_default(self) -> None:
389+
"""forward=False (the default) keeps reverse-chronological order."""
390+
posts = [_make_post(2022, 6, 1), _make_post(2024, 3, 15)]
391+
result_default = build_calendar(posts, "index.html")
392+
result_explicit = build_calendar(posts, "index.html", forward=False)
393+
assert [y.year for y in result_default] == [y.year for y in result_explicit]
394+
395+
def test_forward_preserves_post_count(self) -> None:
396+
"""Post counts are correct when forward=True."""
397+
post = _make_post(2024, 6, 15)
398+
result = build_calendar([post], "index.html", forward=True)
399+
month = result[0].months[0]
400+
for week in month.weeks:
401+
for cell in week:
402+
if cell.date is not None and cell.date.day == 15:
403+
assert cell.post_count == 1
404+
return
405+
raise AssertionError("Day 15 not found in calendar")
406+
407+
def test_forward_year_url_present(self) -> None:
408+
"""Year URL is set when forward=True and the year has posts."""
409+
post = _make_post(2024, 3, 10)
410+
result = build_calendar([post], "index.html", forward=True)
411+
assert result[0].year_url == "/2024/index.html"
412+
413+
def test_forward_month_url_present(self) -> None:
414+
"""Month URL is set when forward=True and the month has posts."""
415+
post = _make_post(2024, 3, 10)
416+
result = build_calendar([post], "index.html", forward=True)
417+
assert result[0].months[0].month_url == "/2024/03/index.html"

tests/test_generator.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5987,3 +5987,32 @@ def test_calendar_canonical_url_with_site_url(
59875987
'<link rel="canonical" href="https://example.com/calendar.html">'
59885988
in calendar_content
59895989
)
5990+
5991+
def test_forward_calendar_default_is_false(self) -> None:
5992+
"""forward_calendar defaults to False on SiteConfig."""
5993+
config = SiteConfig(output_dir=Path("output"))
5994+
assert config.forward_calendar is False
5995+
5996+
def test_forward_calendar_generates_monday_first_headers(
5997+
self, posts_dir: Path, temp_output_dir: Path
5998+
) -> None:
5999+
"""When forward_calendar=True the template renders M T W T F S S headers."""
6000+
generator = SiteGenerator(
6001+
site_config=SiteConfig(
6002+
content_dir=posts_dir,
6003+
output_dir=temp_output_dir,
6004+
with_calendar=True,
6005+
forward_calendar=True,
6006+
)
6007+
)
6008+
generator.generate()
6009+
6010+
calendar_content = (temp_output_dir / "calendar.html").read_text()
6011+
# The DOW header row for a forward calendar should start with M (Monday).
6012+
# We look for the sequential M T W T pattern (Monday-Tuesday-Wednesday-Thursday).
6013+
assert "calendar-dow" in calendar_content
6014+
# Find the section containing the day-of-week headers; Monday should appear
6015+
# before Sunday in forward mode.
6016+
first_m_pos = calendar_content.find(">M<")
6017+
first_s_pos = calendar_content.find(">S<")
6018+
assert first_m_pos < first_s_pos

0 commit comments

Comments
 (0)