Skip to content

BYWEEKNO and Year Boundaries

This guide covers the BYWEEKNO rule part: how ISO 8601 week numbering works, the year-boundary edge case, and where rrule-ts deliberately diverges from python-dateutil and rrule.js.

BYWEEKNO selects occurrences by ISO 8601 week number within a FREQ=YEARLY rule. It looks straightforward but has a well-known edge case at the calendar year boundary: the ISO week year does not always match the calendar year.

ISO 8601 week numbering: the Thursday rule

Section titled “ISO 8601 week numbering: the Thursday rule”

RFC 5545 defers week numbering to ISO 8601. The ISO 8601 rule is: week 1 is the week containing the first Thursday of the year (equivalently, the week that contains January 4). A week belongs to the year that contains its Thursday.

Consequences:

  • Days in late December can belong to ISO week 1 of the next calendar year.
  • Days in early January can belong to ISO week 52 or 53 of the previous calendar year.

For example:

  • 2024-12-30 (Monday) belongs to ISO week 2025-W01, not 2024-W52.
  • 2000-01-01 (Saturday) belongs to ISO week 1999-W52.
  • 2000-01-02 (Sunday) belongs to ISO week 1999-W52.
  • 2000-12-25 through 2000-12-31 all belong to ISO week 2000-W52.

rrule-ts behavior: strict ISO week-year matching

Section titled “rrule-ts behavior: strict ISO week-year matching”

rrule-ts applies the Thursday rule strictly. For FREQ=YEARLY, each iteration covers one calendar year. BYWEEKNO=52 selects only dates whose ISO week-year matches the iteration year.

Example: FREQ=YEARLY;BYWEEKNO=52 starting from DTSTART:20000101T090000

rrule-ts emits exactly seven occurrences for calendar year 2000:

DateISO week-yearISO weekDay
2000-12-25200052Monday
2000-12-26200052Tuesday
2000-12-27200052Wednesday
2000-12-28200052Thursday
2000-12-29200052Friday
2000-12-30200052Saturday
2000-12-31200052Sunday

2000-01-01 and 2000-01-02 are not included, because their ISO week-year is 1999, not 2000. They fall in ISO week 1999-W52, which is a different week-year.

Deliberate divergence from python-dateutil and rrule.js

Section titled “Deliberate divergence from python-dateutil and rrule.js”

python-dateutil and rrule.js both additionally include 2000-01-01 and 2000-01-02 in the result for the same rule. Their reasoning: those two dates are in “week 52” of some ISO week-year, and the rule asks for week 52.

rrule-ts takes the stricter interpretation: FREQ=YEARLY iterates calendar years, so BYWEEKNO=52 selects only dates whose ISO week-year matches the calendar year being iterated. RFC 5545 does not explicitly resolve this backward-boundary ambiguity, so both approaches are defensible. The rrule-ts divergence is deliberate and documented.

The differential conformance suite in packages/conformance/src/diff.test.ts contains a hand-authored test for this case that is intentionally excluded from the oracle-derived corpus.

RFC 5545 restricts BYWEEKNO to FREQ=YEARLY. Using it with any other frequency is a validation error. validate() reports this with ruleId: 'BYWEEKNO_YEARLY_ONLY'.

import { validate } from 'rrule-ts'
const errors = validate({ freq: 'MONTHLY', byWeekNo: [1] })
// errors.ok === false
// errors.error[0].ruleId === 'BYWEEKNO_YEARLY_ONLY'

Negative values count ISO weeks from the end of the year. -1 is the last ISO week (week 52 or 53, depending on whether the year has 52 or 53 ISO weeks).

import { expand } from 'rrule-ts'
import { Temporal } from 'temporal-polyfill'
// Last ISO week of each year for 3 years
expand({
freq: 'YEARLY',
byWeekNo: [-1],
count: 7, // up to 7 days in last week
dtstart: Temporal.PlainDate.from('2025-01-01'),
})

When BYDAY is used alongside BYWEEKNO, it filters the days within each selected week.

// Every Thursday of ISO week 10, for 3 years
expand({
freq: 'YEARLY',
byWeekNo: [10],
byDay: [{ weekday: 'TH', ordinal: undefined }],
count: 3,
dtstart: Temporal.PlainDate.from('2025-01-01'),
})
// One Thursday per year: the Thursday of ISO week 10

The WKST field shifts which day anchors week 1. This changes which days belong to which week number. The default WKST is MO (Monday), matching the ISO 8601 default.

// BYWEEKNO with Sunday week start
expand({
freq: 'YEARLY',
byWeekNo: [1],
wkst: 'SU',
count: 7,
dtstart: Temporal.PlainDate.from('2025-01-01'),
})
// Week 1 boundaries shift when WKST=SU
import { parse, expand } from 'rrule-ts'
import { Temporal } from 'temporal-polyfill'
const result = parse('RRULE:FREQ=YEARLY;BYWEEKNO=52;COUNT=7')
if (!result.ok) throw new Error(result.error)
const dates = expand({
...result.value,
dtstart: Temporal.PlainDate.from('2000-01-01'),
})
// rrule-ts emits Mon-Sun of ISO week 2000-W52: 2000-12-25 through 2000-12-31.
// python-dateutil additionally includes 2000-01-01 and 2000-01-02.