A 90-day recurrence horizon silently truncates your semester

A semester runs about 15 to 16 weeks. A calendar tool that expands repeating events over a fixed 90-day horizon covers only 12.9 of those weeks — the last three weeks of term silently never render. No error, no warning; your calendar stops in November. In building Margin we hit this arithmetic and now expand recurrences to term length instead of a flat 90 days — the 90-day horizon survives only as a fallback for a series that declares no end at all.

The arithmetic, in one line

90 days ÷ 7 = 12.857 weeks, so a 90-day window covers about 12.9 weeks of a term. A weekly series that runs the full term has 15 or 16 occurrences, one per week starting in week one. Everything past the thirteenth falls outside the window.

Term lengthWeekly occurrencesCovered by a 90-day windowMissing
15 weeks15132
16 weeks16133

The horizon is measured in days; the semester is measured in weeks. Nothing reconciles the two, so the shortfall is arithmetic rather than a bug in any one piece of data.

Why it happens at all

A calendar feed does not list your weekly homework twelve or sixteen times. It lists it once, as a single event with a recurrence rule attached:

BEGIN:VEVENT
SUMMARY:CS 225 Homework
DTSTART:20260119T235900
RRULE:FREQ=WEEKLY;COUNT=16
END:VEVENT

Anything reading that feed has to expand the rule into individual dates. An unbounded rule — FREQ=WEEKLY with no COUNT and no UNTIL — would expand forever, so a reader needs some stopping point. Ninety days is one such stopping point, and for a rule that genuinely never ends, any fixed horizon is a defensible choice.

The failure is applying that same stop to a rule that already told you where it ends. COUNT=16 and UNTIL=20260508T235900Z are both explicit bounds. When a reader clips them at 90 days anyway, it discards dates the feed handed it outright. There is no error condition to report — the expansion succeeded but stopped early — which is why the symptom is silence rather than a warning.

What it looks like from the student side

That last symptom is worth testing. Re-subscribe and watch the cliff. If it moves later rather than filling in, you are looking at an expansion window, not a missing assignment.

How to check

  1. Scroll your calendar to the last two weeks of the term and look for the weekly items you know are there — the recurring homework, the lab, the discussion post.
  2. Compare against the syllabus, which states the true end date.
  3. Note the date of the last visible occurrence. If that date sits roughly three months after you subscribed — and moves when you re-subscribe — the series was truncated, not canceled.

What to do about it

Non-product fixes first, because several of them are enough on their own.

If you build something that reads calendar feeds: the rule is narrower than "raise the horizon". Let an explicit COUNT or UNTIL expand fully, and keep a horizon only for the unbounded case. Runaway protection belongs on a maximum occurrence count, which bounds the loop without discarding dates the feed declared.

A note on what a feed can tell you

Margin shows what Canvas exposes — check your syllabus too. A calendar feed carries what the course put on the calendar, and a recurrence rule carries what the instructor set when the series was created. If the tail of your term was never entered into Canvas in the first place, no expansion window, however generous, will produce it.

Evidence

RFC 5545 §3.8.5.3 (Recurrence Rule). COUNT and UNTIL are the two ways a recurrence rule states its own end; a rule carrying neither is unbounded. Both bounds are part of the rule the feed hands its reader.

Margin repo (first-person), extension/ical-parser.js:479-499 — re-grepped 2026-07-30. The comment above our expansion loop records the rule we settled on:

The 90-day horizon is ONLY a fallback for an UNBOUNDED series. An explicit COUNT or UNTIL must be allowed to expand fully even past the horizon -- otherwise a term-length weekly feed (FREQ=WEEKLY;COUNT=16, or UNTIL at term end ~15-16 weeks out) silently loses its tail beyond ~12.8 weeks […] Runaway protection still comes from maxCount (RRULE_MAX_OCCURRENCES) and countLimit (rule.count), enforced in the loop.

The same file anchors the unbounded fallback at Math.max(baseDue, nowMs) + horizonMs — our parser's own choice, recorded there as a fix for an earlier version that anchored at the series start and starved long-lived rules.

Margin repo (first-person), extension/test-ical-parser.js:449-459 — re-grepped 2026-07-30. The arithmetic is pinned by a test:

test('FREQ=WEEKLY;COUNT=16 expands fully even past the 90-day horizon', …) — "90 days is only ~12.86 weeks, so the old unconditional horizon clipped this to 13 and silently dropped the last 3 weeks of graded work." The test asserts all sixteen occurrences are emitted, the sixteenth landing 2026-04-16, about 105 days out.