Table of Contents

Bodu.Globalization.Recurrence - Getting started

Unfamiliar with terms like occurrence, anchor, inclusive boundary, or BYSETPOS? Read Core concepts first.

Install

dotnet add package Bodu.Globalization.Recurrence

Targets net8.0. Depends on Bodu.Core only - no calendar data, no time-zone database, and no dependency on Bodu.Globalization.Calendar.

Status: Preview (see the package matrix).

Every sample below is a pure function of the instants it is handed: substitute your own "now" and the answers are reproducible in a test.

Minimal samples

RFC 5545 recurrence rule - parse, next / previous, enumerate a window

A rule carries no start of its own; the series start (DTSTART) is passed to every query.

using Bodu.Globalization.Recurrence;

RecurrenceRule rule = RecurrenceRule.Parse("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR");

var start = new DateTime(2026, 1, 5, 9, 0, 0);                      // Monday 5 Jan 2026, 09:00

DateTime? next     = rule.GetNextOccurrence(start, after: new DateTime(2026, 1, 20));
DateTime? previous = rule.GetPreviousOccurrence(start, before: new DateTime(2026, 1, 20));
// next     = 2026-01-21 09:00 (Wednesday of the second fortnight)
// previous = 2026-01-19 09:00 (Monday)

// Every occurrence inside an inclusive window:
foreach (DateTime occurrence in rule.GetOccurrences(start, new DateTime(2026, 2, 1), new DateTime(2026, 2, 28)))
    Console.WriteLine(occurrence);                                  // 2 Feb, 4 Feb, 6 Feb, 16 Feb, 18 Feb, 20 Feb - all 09:00

Console.WriteLine(rule);                                            // FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR
Console.WriteLine(rule.Frequency);                                  // Weekly
Console.WriteLine(rule.ByDay.Count);                                // 3

An unbounded rule enumerates to the end of the representable calendar, so bound the open-ended GetOccurrences(start) with Take or use the windowed overload. BYSETPOS turns a weekday list into "the last working day of each month":

RecurrenceRule lastWeekday = RecurrenceRule.Parse("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1");

foreach (DateTime occurrence in lastWeekday.GetOccurrences(new DateTime(2026, 1, 1)).Take(3))
    Console.WriteLine($"{occurrence:yyyy-MM-dd ddd}");              // 2026-01-30 Fri, 2026-02-27 Fri, 2026-03-31 Tue

Build a rule fluently

RecurrenceRuleBuilder is the alternative to parsing text. A BYDAY entry with an ordinal is a WeekDayNum; plain weekdays can be passed as DayOfWeek.

using Bodu.Globalization.Recurrence;

// The last Friday of the month, six times.
RecurrenceRule lastFriday = new RecurrenceRuleBuilder(RecurrenceFrequency.Monthly)
    .ByDay(new WeekDayNum(-1, DayOfWeek.Friday))
    .WithCount(6)
    .Build();

Console.WriteLine(lastFriday);                                      // FREQ=MONTHLY;COUNT=6;BYDAY=-1FR

foreach (DateTime occurrence in lastFriday.GetOccurrences(new DateTime(2026, 1, 1, 17, 0, 0)))
    Console.WriteLine($"{occurrence:yyyy-MM-dd HH:mm}");            // 2026-01-30 17:00 … 2026-06-26 17:00 (six dates)

// Value equality: the built rule equals its parsed canonical text.
Console.WriteLine(lastFriday.Equals(RecurrenceRule.Parse("FREQ=MONTHLY;COUNT=6;BYDAY=-1FR")));   // True

// Every other week on Monday and Thursday until the end of June. WithUntil clears any WithCount and vice versa.
RecurrenceRule fortnightly = new RecurrenceRuleBuilder(RecurrenceFrequency.Weekly)
    .WithInterval(2)
    .ByDay(DayOfWeek.Monday, DayOfWeek.Thursday)
    .WithUntil(new DateTime(2026, 6, 30))
    .Build();

Console.WriteLine(fortnightly);                                     // FREQ=WEEKLY;INTERVAL=2;UNTIL=20260630T000000;BYDAY=MO,TH

Recurrence set - rules plus RDATE / EXDATE, and a round trip

A RecurrenceSet owns its start and composes rules, explicit dates, and exception dates into one ascending, duplicate-free stream. Its canonical text is the iCalendar property block, which Parse reads back to an equal value.

using Bodu.Globalization.Recurrence;

var set = new RecurrenceSet(
    start: new DateTime(2026, 3, 2, 10, 0, 0),                      // Monday
    rules: [RecurrenceRule.Parse("FREQ=WEEKLY;BYDAY=MO;COUNT=4")],
    dates: [new DateTime(2026, 3, 12, 10, 0, 0)],                   // an extra Thursday
    exceptionDates: [new DateTime(2026, 3, 16, 10, 0, 0)]);         // one Monday cancelled

foreach (DateTime occurrence in set.GetOccurrences())
    Console.WriteLine($"{occurrence:yyyy-MM-dd ddd}");              // 03-02 Mon, 03-09 Mon, 03-12 Thu, 03-23 Mon

DateTime? next = set.GetNextOccurrence(new DateTime(2026, 3, 10));  // 2026-03-12 10:00 - the RDATE

// Round trip through the property block (CRLF-separated, no trailing line break):
string block = set.ToString();
// DTSTART:20260302T100000
// RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO
// RDATE:20260312T100000
// EXDATE:20260316T100000

RecurrenceSet reparsed = RecurrenceSet.Parse(block);
Console.WriteLine(set.Equals(reparsed));                            // True

Cron expression - parse, next / previous, enumerate

A CronExpression needs no anchor. Parse(string) infers the five- or six-field layout from the field count; Parse(string, CronFormat) insists on one.

using Bodu.Globalization.Recurrence;

CronExpression nightly = CronExpression.Parse("0 2 * * MON-FRI");     // 02:00 on weekdays
var now = new DateTime(2026, 1, 9, 14, 30, 0);                        // Friday afternoon

DateTime? next     = nightly.GetNextOccurrence(now);                  // 2026-01-12 02:00 (Monday)
DateTime? previous = nightly.GetPreviousOccurrence(now);              // 2026-01-09 02:00 (this morning)

Console.WriteLine(nightly);                                           // 0 2 * * 1,2,3,4,5  - canonical numeric lists
Console.WriteLine(nightly.Format);                                    // Standard

// Enumerate: cron has no GetOccurrences, so loop on the next occurrence.
DateTime? cursor = new DateTime(2026, 1, 12);
for (var i = 0; i < 3 && cursor is not null; i++)
{
    cursor = nightly.GetNextOccurrence(cursor.Value, inclusive: i == 0);
    Console.WriteLine(cursor);                                        // 12 Jan, 13 Jan, 14 Jan - 02:00
}

// Six-field layout with a leading seconds field, and the macros:
CronExpression halfMinute = CronExpression.Parse("*/30 * 9-17 * * *", CronFormat.WithSeconds);
Console.WriteLine(halfMinute.GetNextOccurrence(now));                 // 2026-01-09 14:30:30

CronExpression daily = CronExpression.Parse("@daily");
Console.WriteLine(daily);                                             // 0 0 * * *
Console.WriteLine(daily.Equals(CronExpression.Parse("0 0 * * *")));  // True - value equality

// DateTimeOffset: evaluated on the wall clock, answered in the argument's offset.
var nowInSydney = new DateTimeOffset(2026, 1, 9, 14, 30, 0, TimeSpan.FromHours(10));
DateTimeOffset? nextInSydney = nightly.GetNextOccurrence(nowInSydney); // 2026-01-12 02:00 +10:00

Remember the Vixie day-field rule: when both day fields are restricted an instant matches either - 0 0 13 * FRI fires on every 13th and every Friday. See Cron field semantics.

Anchored interval - "every 4 hours after the last completed run"

An AnchoredInterval produces anchor + k·interval for k ≥ 1; the anchor is passed to every query and is never itself an occurrence.

using Bodu.Globalization.Recurrence;

AnchoredInterval every4h = AnchoredInterval.Parse("PT4H");           // or: new AnchoredInterval(TimeSpan.FromHours(4))

var lastCompleted = new DateTime(2026, 1, 9, 6, 15, 0);
var now           = new DateTime(2026, 1, 9, 15, 0, 0);

DateTime? next     = every4h.GetNextOccurrence(lastCompleted, now);       // 2026-01-09 18:15
DateTime? previous = every4h.GetPreviousOccurrence(lastCompleted, now);   // 2026-01-09 14:15

// The due-ness recipe - a single comparison, no stored state:
bool isDue = lastCompleted < every4h.GetPreviousOccurrence(anchor: lastCompleted, before: now, inclusive: true);   // True

foreach (DateTime occurrence in every4h.GetOccurrences(lastCompleted, new DateTime(2026, 1, 9), new DateTime(2026, 1, 10)))
    Console.WriteLine($"{occurrence:HH:mm}");                             // 10:15, 14:15, 18:15, 22:15

Console.WriteLine(new AnchoredInterval(TimeSpan.FromDays(14)));           // P2W - canonical RFC 5545 duration text
Console.WriteLine(AnchoredInterval.Parse("P1DT2H30M").Interval);          // 1.02:30:00

TryParse with a defect-naming failure message

Every form has the BCL-shaped TryParse(s, out result) and an overload that also reports why parsing failed, in a message suitable for surfacing to the user verbatim.

using Bodu.Globalization.Recurrence;

if (!RecurrenceRule.TryParse("FREQ=WEEKLY;COUNT=3;UNTIL=20261231T000000Z", out RecurrenceRule? _, out string? failure))
    Console.WriteLine(failure);   // The COUNT and UNTIL rule parts cannot both appear in the same rule.

if (!RecurrenceRule.TryParse("FREQ=WEEKLY;BYDAY=XX", out RecurrenceRule? _, out failure))
    Console.WriteLine(failure);   // The recurrence-rule component 'BYDAY=XX' is not valid.

if (!CronExpression.TryParse("0 25 * * *", out CronExpression? _, out failure))
    Console.WriteLine(failure);   // The cron field '25' is not valid.

if (!AnchoredInterval.TryParse("4h", out AnchoredInterval? _, out failure))
    Console.WriteLine(failure);   // An iCalendar duration must begin with the 'P' designator.

if (!RecurrenceSet.TryParse("RRULE:FREQ=DAILY", out RecurrenceSet? _, out failure))
    Console.WriteLine(failure);   // A recurrence set requires a DTSTART property line.

Parse throws FormatException with the same message. Two cases are deliberately not format errors: a cron expression using a Quartz extension token (L, W, #, ?) parses to NotSupportedException, and a rule with a sub-daily FREQ parses successfully but throws NotSupportedException when enumerated.

Where to go next

  • Core concepts - the vocabulary behind these samples: occurrence vs. due-ness, anchor / DTSTART, inclusive boundaries, WKST, the BY* parts, cron semantics, the duration grammar, offsets.
  • Recurrence and scheduling guide - the due-ness recipe for a scheduling host, calendar-aware filtering by composition, bounded searches, and conformance.
  • Runnable samples - the per-form console projects and the SchedulingHost adapter that puts all four forms behind one interface.
  • Bodu.Globalization.Recurrence API reference - full type-by-type docs.